Compare commits

..

55 Commits

Author SHA1 Message Date
nityanandagohain
d81ae64515 fix: refactor integration tests 2026-07-30 14:52:04 +05:30
nityanandagohain
13b25a6be1 Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-30 14:11:04 +05:30
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
nityanandagohain
d6d09549f0 fix: add NewFactory 2026-07-29 19:14:35 +05:30
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
nityanandagohain
d71c0c938a fix: remove accidentally added file 2026-07-29 17:01:11 +05:30
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
nityanandagohain
e911223b1d Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-29 16:45:11 +05:30
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
nityanandagohain
cb3cc3de56 fix: remove comment 2026-07-29 16:04:49 +05:30
nityanandagohain
017e62814a Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-29 15:45:35 +05:30
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
Nityananda Gohain
f10f526249 Merge branch 'main' into issue_5601 2026-07-29 14:14:37 +05:30
Srikanth Chekuri
bca2370862 test(promql): add upstream promqltest conformance corpus and suite (#12156)
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
Freezes an absolute-truth oracle from prometheus@v0.311.3's own
promql/promqltest testdata: a generator command (scripts/promqltestcorpus, go run .)
evaluates upstream's load scripts with the vendored reference engine and
writes 755 cases to a committed corpus; a new integration suite replays
the ingestion and asserts /api/v5/query_range responses against it. The
known-divergences ledger is enforced exactly in both directions and is
empty: the serving path matches the reference engine on every case.

Also folds in the last serving-path fix the corpus surfaced: the v5
output filter dropped every __-prefixed label, mangling labelsets that
legitimately carry one (e.g. __address__); it now strips only known
storage keys (__temporality__, __scope./__resource. prefixes).

The metrics fixture writes registration rows per (series, hour bucket)
with hour-floored timestamps, matching the exporter; the queriermetrics
dormant-metric warning test now uses genuinely dormant data (production-
shaped registration shares an hour bucket with recently-stale data).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:54:22 +00:00
Gaurav Tewari
591837152e fix(warning-popover): background color to l2 & message alignment (#12302)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (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
* fix: update css styles

* feat: add warning popover

* chore: revert bg color change

* feat: update styles

* chore: css conventios

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-28 17:16:05 +00:00
Pandey
2c5b4184c4 feat(querier): log user-authored clickhouse sql after running them through the parser (#12301)
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(querier): restrict user-authored clickhouse sql to read-only selects

Validate every user-authored ClickHouse statement before it runs: exactly one
statement, SELECT only, no table functions and no readonly override. Validation
runs on the rendered statement, since substituted variable values are user input
too.

Apply it to both entry points that reach the telemetry store with user SQL, the
clickhouse_sql query type in query_range and the dashboard variables query, and
replace the substring blacklist in the latter, which ran before substitution.

Also run these statements with readonly = 2 as a backstop. The connection is
shared with write paths, so it is opt-in per query and writers never set it.

* fix(querier): address review on clickhouse sql validation

Rename the validator to ValidateReadOnlySelect and collapse the multi-line error
constructions onto single lines.

Carry the parse failure in the message rather than as a wrapped cause. Neither
renderer surfaces the cause: render.Error reads only the message off the base
error, and RespondError reads Error(), which returns the cause alone and drops
the message. Both now show the same text with a 400.

Add unit tests covering statement kinds, table functions nested in joins, CTEs,
subqueries and unions, and readonly overrides.

* fix(querier): reject internal databases in user-authored clickhouse sql

Reading system or information_schema exposes grants, users and server metadata,
and ClickHouse read-only mode does not prevent it, so reject any table reference
into them.

Update the dashboard variables test for the new rejection message and cover both
a statement smuggled through a variable value and a system table read.

* fix(querier): reject unterminated block comments before parsing

The parser loops forever on an unterminated block comment, so validating a query
containing one would spin a request goroutine at full CPU instead of rejecting
it. Detect it up front, skipping comment markers that sit inside string literals.

Cover the parser panicking on a settings clause with no value in its own test,
which asserts the input still panics the parser so the case cannot quietly stop
exercising the recover.

* fix(querier): drop the block comment guard now the parser handles it

The parser no longer loops on an unterminated block comment, so the hand-rolled
scan that worked around it is redundant, and it was the riskier of the two: it
duplicated the lexer's handling of string literals and could have rejected a
legitimate query. Leave the parser as the single source of truth.

The panic case likewise no longer panics, so its test can no longer cover the
recover and is removed. The recover stays as insurance, since this reaches
user-authored SQL and the parser has regressed this way before.

Cover INTERSECT and EXCEPT, which the parser only started accepting in this
version and which reach a second query through the same rules.

* fix(querier): log clickhouse sql validation failures instead of rejecting

The parser's grammar has gaps against SQL that ClickHouse accepts, so rejecting
whatever it cannot read would break working dashboards and alerts. Sampling a
week of production clickhouse_sql found roughly one query in eighteen tripping
one of three gaps, none of them reading anything a telemetry query should not.

Log the failure with the rendered query instead, so the gaps can be told apart
from statements that genuinely break the rules before anything is enforced.

Wrap the parse error rather than formatting it in, so a caller can recover the
parser's *ParseError and read the position off it. The tests use that to pin the
construct each gap stops at, alongside the rewrite that the parser does accept.

* chore(querier): tighten the clickhouse sql validation comments

Condense the comments to single lines, move the recover note inside the defer it
explains, separate the visitor cases, and shorten the log message. Report how
many statements were found when rejecting a multi-statement query.

* fix(querier): log invalid clickhouse sql on the dashboard variables path too

The two callers disagreed: query_range logged and carried on, while dashboard
variables rejected. Given the parser trips on roughly one real query in eighteen,
that path could refuse a legitimate variable query, and being a rejection it left
nothing behind to show it had happened.

Both now go through LogIfStatementIsNotValid. prepareQuery is back to rendering
and nothing else, so the cases that expected it to police the statement move to
where the rules actually live.

* fix(querier): drop the read-only clickhouse session

Setting readonly on the session was defence in depth for a validator that now
only logs, so it guarded nothing while adding a context key and a settings branch
to a connection that is shared with every write path.

Restore the dashboard variables handler to what it was and call the validation
alongside, rather than reworking the rendering to accommodate it.

* chore(querybuilder): drop the redundant suffix from the failing case names

The test they sit in is already named _Fail.
2026-07-28 14:54:42 +00:00
Gaurav Tewari
0b69f5f677 fix: smooth Only/Toggle hover reveal in quick-filters checkbox (#12248)
The Only and Toggle buttons were revealed by overlapping hover triggers
(`.value` for Toggle, `.checkbox-value-section` for Only), so hovering the
label matched both rules and showed the two buttons side by side, shifting
the row layout — the visible hover "jerk".

- Scope the Toggle trigger to the checkbox (`.check-box:hover ~ ...`) so it
  no longer overlaps the label region; Only and Toggle are now mutually
  exclusive by cursor position.
- Stack both buttons in a single grid cell (`.value-actions`) so revealing
  one swaps in place instead of shifting the row.
- Add a fade + slide entry/exit animation (opacity + translateX with
  `@starting-style` and `display` `allow-discrete`), disabled under
  prefers-reduced-motion.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-28 12:21:39 +00:00
Abhi kumar
a28b95da35 fix(dashboard-v2): keep new-panel Save enabled after running the query (#12311)
* fix(dashboard-v2): keep new-panel Save enabled after running the query

A new panel's Save button re-disabled after Stage & Run: the query-dirty
baseline was derived from the live draft.spec.queries, which the run commits
the current query into, so the baseline drifted onto the just-run query and
isQueryDirty flipped back to false. Freeze the seed used for the new-panel
baseline so a run can't reset it. Existing panels are unaffected (they anchor
to savedQueries).

* fix(dashboard-v2): always allow Save and surface the real save-failure message

Two follow-ups to the new-panel Save regression:

- Decouple the Save button from the dirty check so it is never disabled on
  account of "no changes"; Save is gated only by read-only / in-flight. The
  dirty flag still drives the discard-on-close confirmation.
- On a failed save, show the server's error message in the toast description
  instead of only the generic "Failed to save panel".

* feat(dashboard-v2): show the "Unsaved Changes" badge only when the panel is dirty

Gate the panel-editor header badge on isDirty so it appears only when there
are unsaved edits, and import Badge from the @signozhq/ui/badge subpath.

* chore: replaced toast with the error modal

* fix: dashboard delete calling v1 api
2026-07-28 11:44:55 +00:00
Ashwin Bhatkal
b4b8a9fda0 fix(api): guard ErrorResponseHandlerV2 against non-envelope error bodies (#12209)
* fix(api): guard ErrorResponseHandlerV2 against non-envelope error bodies

The handler assumed every non-2xx response carried the V2 error envelope
and read response.data.error.code directly. During a deployment the
gateway returns a 5xx with an HTML/empty body, so response.data.error is
undefined and the handler threw its own TypeError, masking the real
failure and crashing the caller.

Type the inbound body as unknown and narrow it with an isErrorV2Resp type
guard; when the body isn't a V2 envelope, synthesize an APIError from the
HTTP status instead of throwing. Callers already pass
AxiosError<ErrorV2Resp>, which stays assignable to AxiosError<unknown>.

* refactor(api): keep ErrorV2Resp signature, launder response.data via unknown

Restore the AxiosError<ErrorV2Resp> signature so it documents that this is
the V2 error handler, and confine the runtime uncertainty to the one spot
that matters: launder response.data through a local unknown binding before
narrowing it with the isErrorV2Resp guard.

* fix(api): use UPSTREAM_UNAVAILABLE code for non-envelope error bodies

Address review: when the response body isn't a V2 envelope (e.g. a gateway
5xx during a deploy), throw a stable UPSTREAM_UNAVAILABLE code instead of
the stringified HTTP status, and trim the guard comment.

* fix(api): use UPSTREAM_UNAVAILABLE fallback code in convertToApiError

When the response body carries no error code, fall back to a stable
UPSTREAM_UNAVAILABLE code instead of a stringified HTTP status, mirroring
the ErrorResponseHandlerV2 fallback.

* test(dashboard-v2): update panelStatus fallback code to UPSTREAM_UNAVAILABLE

convertToApiError now returns UPSTREAM_UNAVAILABLE (not a stringified
status) when the response carries no error code; update the panelStatus
fallback assertion to match.

* fix(api): guard generated-API handler + strengthen V2 handler tests

- Guard the deprecated ErrorResponseHandlerForGeneratedAPIs against a
  non-envelope response body (gateway 5xx with HTML/empty body), mirroring
  ErrorResponseHandlerV2 — falls back to UPSTREAM_UNAVAILABLE instead of
  throwing on response.data.error.code.
- Parametrize the V2 handler tests into an { error, expected } table and
  assert the sub-error 'errors' messages, which several UI surfaces rely on.

* revert(api): drop generated-API handler guard from this PR

The deprecated ErrorResponseHandlerForGeneratedAPIs guard broke toAPIError's
defaultMessage fallback (which relied on the handler crashing), regressing the
error UX in ServiceAccount/Roles screens. Moved to a stacked PR + tracked in
engineering-pod#5761. Keeps this PR scoped to ErrorResponseHandlerV2 +
convertToApiError, and the strengthened V2 handler tests remain.

* fix(api): guard deprecated generated-API handler against non-envelope bodies (#12228)
2026-07-28 11:31:26 +00:00
nityanandagohain
a5350682c3 fix: remove tracefield. explicit rejection 2026-07-28 16:54:26 +05:30
nityanandagohain
a7b74005ea fix: address comments 2026-07-28 16:43:20 +05:30
Ashwin Bhatkal
de59c123e1 fix(metrics-explorer): fetch related dashboards from the v3 API (#12312)
The metric details drawer resolved "N dashboards" through
`GET /api/v2/metrics/dashboards`, whose backend walks the raw `data["widgets"]`
array and so only matches v1-schema dashboards. Since #12249 migrated stored
dashboards to the Perses v6 schema and made `/dashboard/:id` render V2
unconditionally, that endpoint has nothing left to match and the popover
silently shows no dashboards.

Switch to `useGetMetricDashboardsV2` (`GET /api/v3/metrics/dashboards`, added in
#11784), which parses the Perses spec. Response items change from
`MetricDashboard` (`widgetId`/`widgetName`) to `DashboardPanelRef`
(`panelId`/`panelName`); the popover only reads `dashboardId`/`dashboardName`,
so the render path is unchanged.

Alerts stay on `/api/v2/metrics/alerts` — there is no v3 counterpart, and it
reads the rule store rather than dashboard schema, so it is unaffected.
2026-07-28 10:33:56 +00:00
Nityananda Gohain
787e1be974 Merge branch 'main' into issue_5601 2026-07-28 15:55:47 +05:30
nityanandagohain
19ee51be66 fix: address comments 2026-07-28 15:50:28 +05:30
Abhi kumar
9aeb9a8240 fix(drilldown): carry the field type through a breakout (#12309)
getBreakoutQuery rebuilt the group-by as `{ key, type }`, dropping `dataType`.
The breakout query then goes out with no field type for the backend to work
from, and a drilldown on the breakout's own result has nothing to branch on —
so a numeric column's filter value gets quoted like a string.

Carrying it through needed the type to be nameable, and it wasn't:
QueryKeyDataSuggestionsProps declared `fieldDataType` as the antlr key-type
enum (`string | number | boolean`), which that endpoint never returns. Five
call sites already cast it back to FieldDataType to get work done.

- declare the suggestions response's `fieldDataType` as FieldDataType, and drop
  the casts that existed only to undo the wrong declaration
- convert where a reported type becomes a query-builder one, so the crossing is
  a function rather than a cast. The table is keyed `Record<FieldDataType, …>`,
  so a type added to the API union fails to compile here instead of going
  missing at runtime
- carry `dataType` through getBreakoutQuery; the object now satisfies
  BaseAutocompleteData on its own, so that cast goes too

Depends on the operator-lookup fix in #12298: a numeric breakout now carries
`float64`, and before that change the menu threw on anything the operator map
didn't have.
2026-07-28 07:38:29 +00:00
Pandey
44828b4185 chore(deps): bump clickhouse-sql-parser to v0.5.2 (#12303)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (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
* chore(deps): bump clickhouse-sql-parser to v0.5.2

The parser hangs in an infinite loop on an unterminated block comment and panics
on a settings clause with no value, both reachable from user-authored SQL. v0.5.2
fixes those, along with Accept and Walk missing AST children, which anything
walking the tree to inspect a query depends on.

String() on Expr is gone in favour of FormatSQL, so the callers that rendered a
node back to SQL now go through the Format helper, which formats compactly the
way String() did.

* test(querierlogs): cover the rate aggregation family

rate and rate_sum divide by the query window rather than the step, and nothing
exercised that path, so a change to how the aggregation expression is rendered
would have gone unnoticed. Assert both against the inserted logs.

The window is now passed to the request helper instead of relying on its default,
since these are the only assertions in the file that depend on it.

* test(querierlogs): match the response rounding for rate expectations

Scalar responses round floats to three significant figures below one, and the
rates are the only values in this test that do not divide evenly, so compare
against the rounded form rather than the exact quotient.

* test(queriertraces): cover the rate aggregation family

Traces derives the scalar rate window separately from logs, so a divergence
between the two would go unnoticed with only the logs case. rate_sum here is
taken over the intrinsic duration column, which also puts it on the other side
of the response rounding boundary from the logs test.
2026-07-27 20:30:16 +00:00
nityanandagohain
b2c5af428e fix: refactor as requested 2026-07-24 12:07:37 +05:30
nityanandagohain
093f9b41c4 fix: minor cleanup 2026-07-22 16:22:19 +05:30
nityanandagohain
596e128005 fix: updated openapi 2026-07-22 15:26:52 +05:30
nityanandagohain
2e1da92367 fix: remove source and change to builder ai query 2026-07-22 15:24:43 +05:30
nityanandagohain
1e8d72e8fa Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-21 17:08:34 +05:30
nityanandagohain
cce080e1ae fix: add back the flag in metadata 2026-07-16 11:37:57 +05:30
nityanandagohain
deca060a4d Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-16 11:00:55 +05:30
nityanandagohain
03c7e524e7 fix: fix tests 2026-07-15 20:23:33 +05:30
nityanandagohain
815dc7d88b Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-15 20:10:05 +05:30
nityanandagohain
f50d9199fe fix: address comments 2026-07-15 19:47:39 +05:30
nityanandagohain
31efe177a4 fix: address comments 2026-07-14 18:53:30 +05:30
nityanandagohain
d502d12ac3 fix: update openapi 2026-07-10 14:27:07 +05:30
nityanandagohain
bd9f15a716 fix: update integration test 2026-07-10 14:21:16 +05:30
nityanandagohain
813ef988c9 fix: edge cases and correct cost key 2026-07-10 12:06:34 +05:30
nityanandagohain
40e6799285 fix: add resource fingerprint cte 2026-07-10 00:36:25 +05:30
nityanandagohain
1caa60a3cd fix: cleanup and more tests 2026-07-09 23:54:05 +05:30
nityanandagohain
3f781f0083 fix: more cleanup 2026-07-09 12:39:01 +05:30
nityanandagohain
6aec05cf7a fix: more tests 2026-07-09 08:45:22 +05:30
nityanandagohain
683a52f35a fix: take perf into consideration 2026-07-09 08:45:22 +05:30
nityanandagohain
e924fa1e62 feat: support llm trace list and span list 2026-07-09 08:45:20 +05:30
263 changed files with 142567 additions and 1947 deletions

View File

@@ -53,7 +53,9 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- promqlconformance
- querierauthz
- role
- rootuser

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,7 @@ components:
- redis
- cloudsql_postgres
- memorystore_redis
- computeengine
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -6899,6 +6900,7 @@ components:
Querybuildertypesv5QueryEnvelope:
discriminator:
mapping:
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
@@ -6907,6 +6909,7 @@ components:
propertyName: type
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
@@ -6921,6 +6924,15 @@ components:
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeBuilderAI:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
spec:
@@ -7034,6 +7046,7 @@ components:
Querybuildertypesv5QueryType:
enum:
- builder_query
- builder_ai_query
- builder_formula
- builder_trace_operator
- clickhouse_sql

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

@@ -2,6 +2,29 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
// The wire shape these handlers can actually rely on. The generated
// RenderErrorResponseDTO marks code/message/url/errors as required, but the
// server omits any of them even on valid errors (e.g. a 400 with just a
// message), so a present `error` object is all the guard can guarantee.
type ErrorEnvelope = {
error: {
code?: string;
message?: string;
url?: string;
errors?: { message?: string }[];
};
};
function isErrorEnvelope(data: unknown): data is ErrorEnvelope {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as ErrorEnvelope).error === 'object' &&
(data as ErrorEnvelope).error !== null
);
}
// @deprecated Use convertToApiError instead
export function ErrorResponseHandlerForGeneratedAPIs(
error: AxiosError<RenderErrorResponseDTO>,
@@ -10,15 +33,29 @@ export function ErrorResponseHandlerForGeneratedAPIs(
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
// The body isn't guaranteed to be an error envelope — e.g. a gateway 5xx
// with an HTML/empty body during a deploy. Verify the shape before reading
// it; otherwise synthesize a consistent error from the status.
const data: unknown = response.data;
if (isErrorEnvelope(data)) {
const { code, message, url, errors } = data.error;
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: code ?? '',
message: message ?? '',
url: url ?? '',
errors: (errors ?? []).map((e) => ({ message: e.message ?? '' })),
},
});
}
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url ?? '',
errors: (response.data.error.errors ?? []).map((e) => ({
message: e.message ?? '',
})),
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
},
});
}
@@ -62,9 +99,7 @@ export function convertToApiError(
return new APIError({
httpStatusCode: response?.status || error.status || 500,
error: {
code:
errorData?.code ||
String(response?.status || error.code || 'unknown_error'),
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
message:
errorData?.message ||
response?.statusText ||

View File

@@ -2,19 +2,38 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
function isErrorV2Resp(data: unknown): data is ErrorV2Resp {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as ErrorV2Resp).error?.code === 'string'
);
}
// reference - https://axios-http.com/docs/handling_errors
export function ErrorResponseHandlerV2(error: AxiosError<ErrorV2Resp>): never {
const { response, request } = error;
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
// response.data isn't guaranteed to be a V2 envelope (e.g. a gateway 5xx
// with an HTML/empty body during a deploy), so verify the shape first.
const data: unknown = response.data;
if (isErrorV2Resp(data)) {
const { code, message, url, errors } = data.error;
throw new APIError({
httpStatusCode: response.status || 500,
error: { code, message, url, errors },
});
}
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url,
errors: response.data.error.errors,
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
},
});
}

View File

@@ -0,0 +1,126 @@
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
function asAxiosError(partial: Partial<AxiosError>): AxiosError<ErrorV2Resp> {
return partial as AxiosError<ErrorV2Resp>;
}
// ErrorResponseHandlerV2 always throws — capture the APIError so assertions stay
// unconditional.
function runHandler(error: AxiosError<ErrorV2Resp>): APIError {
try {
ErrorResponseHandlerV2(error);
} catch (thrown) {
return thrown as APIError;
}
throw new Error('expected ErrorResponseHandlerV2 to throw');
}
type ExpectedError = {
httpStatusCode: number;
code: string;
message: string;
errors: { message: string }[];
};
// One row per response shape the handler must normalize. New shapes (with
// different bodies) can be added here without a new test block.
const cases: {
name: string;
error: AxiosError<ErrorV2Resp>;
expected: ExpectedError;
}[] = [
{
name: 'well-formed V2 error envelope',
error: asAxiosError({
message: 'Request failed with status code 400',
response: {
status: 400,
data: {
error: {
code: 'bad_request',
message: 'Invalid dashboard payload',
url: 'https://signoz.io/docs',
errors: [{ message: 'name is required' }, { message: 'name too long' }],
},
},
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 400,
code: 'bad_request',
message: 'Invalid dashboard payload',
errors: [{ message: 'name is required' }, { message: 'name too long' }],
},
},
{
// Regression: during a deployment the gateway returns a 5xx with a
// non-envelope body. Reading response.data.error.code used to throw a
// TypeError from inside the handler itself. See engineering-pod#5760.
name: '5xx with a non-envelope HTML body',
error: asAxiosError({
message: 'Request failed with status code 503',
response: {
status: 503,
data: '<html><body>503 Service Temporarily Unavailable</body></html>',
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 503,
code: 'UPSTREAM_UNAVAILABLE',
message: 'Request failed with status code 503',
errors: [],
},
},
{
name: '5xx with an empty body',
error: asAxiosError({
message: 'Request failed with status code 502',
response: {
status: 502,
data: undefined,
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 502,
code: 'UPSTREAM_UNAVAILABLE',
message: 'Request failed with status code 502',
errors: [],
},
},
{
name: 'no response received (network error)',
error: asAxiosError({
message: 'Network Error',
code: 'ERR_NETWORK',
name: 'AxiosError',
request: {},
}),
expected: {
httpStatusCode: 500,
code: 'ERR_NETWORK',
message: 'Network Error',
errors: [],
},
},
];
describe('ErrorResponseHandlerV2', () => {
it.each(cases)(
'normalizes $name into a consistent APIError',
({ error, expected }) => {
const apiError = runHandler(error);
expect(apiError).toBeInstanceOf(APIError);
expect(apiError.getHttpStatusCode()).toBe(expected.httpStatusCode);
expect(apiError.getErrorCode()).toBe(expected.code);
expect(apiError.getErrorMessage()).toBe(expected.message);
// The sub-error messages feed several parts of the UI, so assert them.
expect(apiError.getErrorDetails().error.errors).toStrictEqual(
expected.errors,
);
},
);
});

View File

@@ -2815,6 +2815,7 @@ export enum CloudintegrationtypesServiceIDDTO {
redis = 'redis',
cloudsql_postgres = 'cloudsql_postgres',
memorystore_redis = 'memorystore_redis',
computeengine = 'computeengine',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4298,6 +4299,18 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
}
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
builder_ai_query = 'builder_ai_query',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
/**
* @type string
* @enum builder_ai_query
*/
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
/**
* @type boolean
@@ -4481,6 +4494,7 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
export type Querybuildertypesv5QueryEnvelopeDTO =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
@@ -8284,6 +8298,7 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_ai_query = 'builder_ai_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',

View File

@@ -8,7 +8,6 @@ import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import {
FieldContext,
FieldDataType,
SignalType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
@@ -55,7 +54,7 @@ function OtherFields({
key: buildCompositeKey(attr.name, attr.fieldContext as string),
signal: attr.signal as SignalType,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
fieldDataType: attr.fieldDataType,
}),
);
const addedIds = new Set(

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

@@ -25,12 +25,12 @@ import CodeMirror, {
} from '@uiw/react-codemirror';
import { Button, Popover, Tooltip } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Info, TriangleAlert } from '@signozhq/icons';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { FieldDataType } from 'types/api/v5/queryRange';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
@@ -323,16 +323,16 @@ function QueryAggregationSelect({
TracesAggregatorOperator.RATE,
];
const fieldDataType =
const fieldDataType: FieldDataType | undefined =
functionContextForFetch &&
operatorsWithoutDataType.includes(functionContextForFetch)
? undefined
: QUERY_BUILDER_KEY_TYPES.NUMBER;
: 'number';
return getKeySuggestions({
signal: queryData.dataSource,
searchText: '',
fieldDataType: fieldDataType as QUERY_BUILDER_KEY_TYPES,
fieldDataType,
});
},
{

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

@@ -92,52 +92,76 @@
color: var(--l3-foreground);
}
.only-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
.only-btn,
.toggle-btn {
cursor: not-allowed;
color: var(--l3-foreground);
}
}
.only-btn {
display: none;
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
// one swaps in place instead of shifting the row layout.
.value-actions {
display: grid;
align-items: center;
justify-items: end;
> * {
grid-area: 1 / 1;
}
}
.only-btn,
.toggle-btn {
display: none;
}
.toggle-btn:hover {
background-color: unset;
}
.only-btn:hover {
background-color: unset;
}
}
.checkbox-value-section:hover {
.toggle-btn {
display: none;
}
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
opacity: 0;
transform: translateX(4px);
// `display` is animated as a discrete property so the button
// stays mounted through its fade-out on exit.
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
&:hover {
background-color: unset;
}
}
}
// Hovering the label/value area reveals the "Only"/"All" action.
.checkbox-value-section:hover .only-btn {
display: flex;
opacity: 1;
transform: translateX(0);
// Entry animation start state (fires on the display switch).
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
// Hovering the checkbox reveals the "Toggle" action.
.check-box:hover ~ .checkbox-value-section .toggle-btn {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
}
.value:hover {
@media (prefers-reduced-motion: reduce) {
.only-btn,
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
transition: none;
}
}
}

View File

@@ -53,12 +53,14 @@ function CheckboxValueRow({
</Typography.Text>
</TooltipSimple>
)}
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
<div className="value-actions">
<Button type="text" className="only-btn">
{onlyButtonLabel}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
</div>
);

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

@@ -1,6 +1,11 @@
.warning-popover-overlay {
--antd-arrow-background-color: var(--l2-background);
}
.warning-content {
display: flex;
flex-direction: column;
background-color: var(--l2-background);
// === SECTION: Summary (Top)
&__summary-section {
@@ -10,16 +15,25 @@
&__summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
padding: 16px;
}
&__summary-left {
display: flex;
align-items: baseline;
align-items: flex-start;
gap: 8px;
}
&__icon-wrapper {
display: flex;
align-items: center;
flex-shrink: 0;
height: var(--spacing-12);
}
&__summary-text {
display: flex;
flex-direction: column;

View File

@@ -2,6 +2,7 @@ import { ReactNode, useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Popover, PopoverProps } from 'antd';
import ErrorIcon from 'assets/Error';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { BookOpenText, ChevronsDown, TriangleAlert } from '@signozhq/icons';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
@@ -30,16 +31,18 @@ export function WarningContent({ warning }: WarningContentProps): JSX.Element {
{/* Summary Header */}
<section className="warning-content__summary-section">
<header className="warning-content__summary">
<div className="warning-content__summary-left">
<div className="warning-content__icon-wrapper">
<ErrorIcon />
</div>
{(warningCode || warningMessage) && (
<div className="warning-content__summary-left">
<div className="warning-content__icon-wrapper">
<ErrorIcon />
</div>
<div className="warning-content__summary-text">
<h2 className="warning-content__warning-code">{warningCode}</h2>
<p className="warning-content__warning-message">{warningMessage}</p>
<div className="warning-content__summary-text">
<h2 className="warning-content__warning-code">{warningCode}</h2>
<p className="warning-content__warning-message">{warningMessage}</p>
</div>
</div>
</div>
)}
{warningUrl && (
<div className="warning-content__summary-right">
@@ -154,6 +157,10 @@ function WarningPopover({
overlayInnerStyle={{ padding: 0 }}
autoAdjustOverflow
{...popoverProps}
overlayClassName={cx(
'warning-popover-overlay',
popoverProps.overlayClassName,
)}
>
{children || (
<TriangleAlert

View File

@@ -1,8 +1,5 @@
// ** Helpers
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
@@ -392,19 +389,11 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
// Monotonicity carries meaning only for cumulative sums: a non-monotonic
// cumulative sum is a gauge for all practical purposes. Delta sums are
// counters regardless of monotonicity.
if (
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative
) {
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';

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

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

@@ -33,7 +33,6 @@ function AllAttributes({
metricName,
metricType,
isMonotonic,
temporality,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
@@ -72,7 +71,6 @@ function AllAttributes({
groupBy,
limit,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -91,7 +89,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
@@ -103,7 +101,6 @@ function AllAttributes({
undefined,
undefined,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -123,7 +120,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(

View File

@@ -6,7 +6,7 @@ import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
useGetMetricAlerts,
useGetMetricDashboards,
useGetMetricDashboardsV2,
} from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
@@ -38,7 +38,7 @@ function DashboardsAndAlertsPopover({
data: dashboardsData,
isLoading: isLoadingDashboards,
isError: isErrorDashboards,
} = useGetMetricDashboards(
} = useGetMetricDashboardsV2(
{
metricName,
},
@@ -55,7 +55,8 @@ function DashboardsAndAlertsPopover({
const dashboards = useMemo(() => {
const currentDashboards = dashboardsData?.data.dashboards ?? [];
// Remove duplicate dashboards
// The API returns one entry per referencing panel, so a dashboard repeats
// once per panel that uses the metric.
return currentDashboards.filter(
(dashboard, index, self) =>
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),

View File

@@ -86,7 +86,6 @@ function MetricDetails({
undefined,
undefined,
metadata?.isMonotonic,
metadata?.temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -109,7 +108,6 @@ function MetricDetails({
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
metadata?.temporality,
]);
useEffect(() => {
@@ -198,7 +196,6 @@ function MetricDetails({
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
temporality={metadata?.temporality}
minTime={minTime}
maxTime={maxTime}
/>

View File

@@ -23,7 +23,7 @@ const useGetMetricAlertsMock = jest.spyOn(
);
const useGetMetricDashboardsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricDashboards',
'useGetMetricDashboardsV2',
);
describe('DashboardsAndAlertsPopover', () => {
@@ -153,11 +153,15 @@ describe('DashboardsAndAlertsPopover', () => {
);
});
it('renders unique dashboards even when there are duplicates', async () => {
it('collapses multiple panels of the same dashboard into one entry', async () => {
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
dashboards: [
MOCK_DASHBOARD_1,
MOCK_DASHBOARD_2,
{ ...MOCK_DASHBOARD_1, panelId: '3', panelName: 'Panel 3' },
],
},
}),
);

View File

@@ -2,7 +2,7 @@ import * as metricsExplorerHooks from 'api/generated/services/metrics';
import {
GetMetricAlerts200,
GetMetricAttributes200,
GetMetricDashboards200,
GetMetricDashboardsV2200,
GetMetricHighlights200,
GetMetricMetadata200,
MetrictypesTemporalityDTO,
@@ -40,14 +40,14 @@ export function getMockMetricHighlightsData(
export const MOCK_DASHBOARD_1 = {
dashboardName: 'Dashboard 1',
dashboardId: '1',
widgetId: '1',
widgetName: 'Widget 1',
panelId: '1',
panelName: 'Panel 1',
};
export const MOCK_DASHBOARD_2 = {
dashboardName: 'Dashboard 2',
dashboardId: '2',
widgetId: '2',
widgetName: 'Widget 2',
panelId: '2',
panelName: 'Panel 2',
};
export const MOCK_ALERT_1 = {
alertName: 'Alert 1',
@@ -59,7 +59,7 @@ export const MOCK_ALERT_2 = {
};
export function getMockDashboardsData(
overrides?: Partial<GetMetricDashboards200>,
overrides?: Partial<GetMetricDashboardsV2200>,
{
isLoading = false,
isError = false,
@@ -67,7 +67,7 @@ export function getMockDashboardsData(
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboardsV2> {
return {
data: {
data: {
@@ -79,7 +79,7 @@ export function getMockDashboardsData(
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboardsV2>;
}
export function getMockAlertsData(

View File

@@ -147,44 +147,6 @@ describe('MetricDetails utils', () => {
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should treat a non-monotonic cumulative SUM as a gauge', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.cumulative,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
});
it('should treat a non-monotonic delta SUM as a counter', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.delta,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,

View File

@@ -35,7 +35,6 @@ export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
temporality?: MetrictypesTemporalityDTO;
minTime?: number;
maxTime?: number;
}

View File

@@ -89,21 +89,16 @@ export function getMetricDetailsQuery(
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
// Monotonicity carries meaning only for cumulative sums; delta sums are
// counters regardless of monotonicity.
const isNonMonotonicCumulativeSum =
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative;
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
switch (metricType) {
case MetrictypesTypeDTO.sum:
if (isNonMonotonicCumulativeSum) {
if (isNonMonotonicSum) {
timeAggregation = 'avg';
spaceAggregation = 'avg';
aggregateOperator = 'avg';
@@ -136,7 +131,7 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
const attributeType = toAttributeType(metricType, isMonotonic);
return {
...initialQueriesMap[DataSource.METRICS],

View File

@@ -113,7 +113,7 @@ const useOptionsMenu = ({
(suggestion) => ({
name: suggestion.name,
signal: suggestion.signal as SignalType,
fieldDataType: suggestion.fieldDataType as FieldDataType,
fieldDataType: suggestion.fieldDataType,
fieldContext: suggestion.fieldContext as FieldContext,
}),
);
@@ -192,7 +192,7 @@ const useOptionsMenu = ({
name: e.name,
signal: e.signal as SignalType,
fieldContext: e.fieldContext as FieldContext,
fieldDataType: e.fieldDataType as FieldDataType,
fieldDataType: e.fieldDataType,
}));
}
if (dataSource === DataSource.TRACES) {

View File

@@ -8,7 +8,6 @@ import {
} from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -71,7 +70,7 @@ function makeMetric(
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
description: '',
temporality: MetrictypesTemporalityDTO.cumulative,
temporality: 'cumulative' as never,
unit: '',
...overrides,
};
@@ -394,13 +393,12 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('non-monotonic cumulative Sum metric is treated as Gauge', () => {
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: MetrictypesTemporalityDTO.cumulative,
}),
]);
@@ -429,36 +427,6 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('non-monotonic delta Sum metric remains a counter with Rate/Increase options', () => {
returnMetrics([
makeMetric({
metricName: 'gcp_reject_connections_count',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: MetrictypesTemporalityDTO.delta,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'gcp_reject_connections_count' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toStrictEqual([
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toStrictEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({

View File

@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
return toAttributeType(metric.type, metric.isMonotonic);
}
function createAutocompleteData(

View File

@@ -4,11 +4,11 @@ import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import useDebounce from 'hooks/useDebounce';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { BreakoutOptionsProps } from './contextConfig';
import { BreakoutAttributeType } from './types';
@@ -80,7 +80,7 @@ function BreakoutOptions({
keyArray.forEach((keyData) => {
transformedOptions.push({
key: keyData.name,
dataType: keyData.fieldDataType as QUERY_BUILDER_KEY_TYPES,
dataType: fieldDataTypeToDataType(keyData.fieldDataType),
type: '',
});
});

View File

@@ -238,6 +238,10 @@ describe('TableDrilldown Breakout Functionality', () => {
expect(aggregateQueryData.groupBy).toHaveLength(1);
expect(aggregateQueryData.groupBy[0].key).toBe('deployment.environment');
// The picked field's type travels with it — dropping it leaves the breakout query
// untyped, so a drilldown on its result can't tell a number from a string.
expect(aggregateQueryData.groupBy[0].dataType).toBe('string');
// Verify that orderBy has been cleared (as per getBreakoutQuery logic)
expect(aggregateQueryData.orderBy).toStrictEqual([]);

View File

@@ -1,6 +1,5 @@
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import cloneDeep from 'lodash-es/cloneDeep';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { addFilterToSelectedQuery, FilterData } from './drilldownUtils';
@@ -89,7 +88,11 @@ export const getBreakoutQuery = (
.filter((item: IBuilderQuery) => item.queryName === aggregateData.queryName)
.map((item: IBuilderQuery) => ({
...item,
groupBy: [{ key: groupBy.key, type: groupBy.type } as BaseAutocompleteData],
// The picked field's type travels with it: dropped, the breakout query goes out
// untyped and a drilldown on its result can't tell a number from a string.
groupBy: [
{ key: groupBy.key, dataType: groupBy.dataType, type: groupBy.type },
],
orderBy: [],
legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : '',
}));

View File

@@ -1,6 +1,8 @@
import { ReactNode } from 'react';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export type ContextMenuItem = ReactNode;
@@ -31,7 +33,8 @@ export interface AggregateContextMenuConfig {
export interface BreakoutAttributeType {
key: string;
dataType: QUERY_BUILDER_KEY_TYPES;
/** The picked field's type, in the vocabulary the group-by it becomes is read with. */
dataType: DataTypes;
type: string;
}

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

@@ -11,3 +11,9 @@
border: 1px solid var(--l3-border) !important;
box-shadow: none !important;
}
/* Highlights the dashboard name inside the delete-confirm title. */
.deleteName {
color: var(--danger-background);
font-weight: var(--font-weight-medium);
}

View File

@@ -27,16 +27,13 @@ import logEvent from 'api/common/logEvent';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import DashboardSettings from '../../DashboardSettings';
@@ -45,6 +42,7 @@ import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitl
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -84,8 +82,12 @@ function DashboardActions({
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
const { contextHolder: deleteConfirmHolder, confirmDeleteDashboard } =
useDeleteDashboardAction({
dashboardId: dashboard.id,
dashboardName: title,
panelCount: Object.keys(dashboard.spec.panels).length,
});
// Open the settings drawer when something in the tree requests it (e.g. the
// variables bar's "Add variable" button).
@@ -132,19 +134,6 @@ function DashboardActions({
}
}, [dashboard.id, title, safeNavigate, showErrorModal]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
void logEvent(DashboardDetailEvents.Deleted, {
dashboardId: dashboard.id,
panelCount: Object.keys(dashboard.spec.panels).length,
});
setIsDeleteOpen(false);
history.replace(ROUTES.ALL_DASHBOARD);
},
});
}, [deleteDashboardMutation, dashboard.id, dashboard.spec.panels]);
const handleOpenSettings = useCallback((): void => {
void logEvent(DashboardDetailEvents.SettingsOpened, {
dashboardId: dashboard.id,
@@ -248,7 +237,7 @@ function DashboardActions({
icon: <Trash2 size={14} />,
danger: true,
disabled: isLocked,
onClick: (): void => setIsDeleteOpen(true),
onClick: confirmDeleteDashboard,
},
);
}
@@ -266,6 +255,7 @@ function DashboardActions({
handleClone,
onLockToggle,
handleEnterFullScreen,
confirmDeleteDashboard,
]);
return (
@@ -348,14 +338,7 @@ function DashboardActions({
isOpen={isJsonEditorOpen}
onClose={(): void => setIsJsonEditorOpen(false)}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
isLoading={deleteDashboardMutation.isLoading}
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
{deleteConfirmHolder}
<SectionTitleModal
open={isNewSectionOpen}
heading="New section"

View File

@@ -0,0 +1,89 @@
import { type ReactNode, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import {
invalidateListDashboardsForUserV2,
useDeleteDashboardV2,
} from 'api/generated/services/dashboard';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import ROUTES from 'constants/routes';
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
import history from 'lib/history';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './DashboardActions.module.scss';
interface UseDeleteDashboardActionArgs {
dashboardId: string;
dashboardName: string;
panelCount: number;
}
interface UseDeleteDashboardAction {
/** Must be rendered in the calling component for the modal to appear. */
contextHolder: ReactNode;
confirmDeleteDashboard: () => void;
}
/**
* Deletes the open dashboard through the v2 endpoint behind the shared
* destructive confirmation, then drops its local preferences, refreshes the
* dashboard list cache and sends the user back to the list.
*/
export function useDeleteDashboardAction({
dashboardId,
dashboardName,
panelCount,
}: UseDeleteDashboardActionArgs): UseDeleteDashboardAction {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { contextHolder, confirmDelete } = useDeleteConfirm();
const removePreferences = useDashboardPreferencesStore(
(state) => state.removePreferences,
);
const { mutate: deleteDashboard } = useDeleteDashboardV2({
mutation: {
onSuccess: async (): Promise<void> => {
void logEvent(DashboardDetailEvents.Deleted, { dashboardId, panelCount });
removePreferences(dashboardId);
await invalidateListDashboardsForUserV2(queryClient);
toast.success('Dashboard deleted successfully');
history.replace(ROUTES.ALL_DASHBOARD);
},
onError: (error: unknown): void => {
showErrorModal(error as APIError);
},
},
});
const confirmDeleteDashboard = useCallback((): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the
<Typography.Text className={styles.deleteName}>
{' '}
{dashboardName}{' '}
</Typography.Text>
dashboard?
</Typography.Title>
),
content: 'This action cannot be undone.',
// Keeps the Delete button loading until the mutation settles, then closes.
onConfirm: () =>
new Promise<void>((resolve) => {
deleteDashboard(
{ pathParams: { id: dashboardId } },
{ onSettled: () => resolve() },
);
}),
});
}, [confirmDelete, dashboardName, deleteDashboard, dashboardId]);
return { contextHolder, confirmDeleteDashboard };
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { SolidAlertTriangle, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
@@ -13,6 +14,7 @@ import DisabledControlTooltip from '../../components/DisabledControlTooltip/Disa
import styles from './Header.module.scss';
interface HeaderProps {
/** Unsaved edits exist — shows the "Unsaved Changes" badge and gates the discard confirmation on close (not the Save button). */
isDirty: boolean;
isSaving: boolean;
showSwitchToView?: boolean;
@@ -66,6 +68,11 @@ function Header({
/>
<Divider type="vertical" />
<Typography.Text>Configure panel</Typography.Text>
{isDirty && (
<Badge color="warning" data-testid="panel-editor-v2-unsaved-badge">
Unsaved Changes
</Badge>
)}
</div>
<div className={styles.actions}>
<HeaderRightSection
@@ -88,7 +95,7 @@ function Header({
variant="solid"
color="primary"
data-testid="panel-editor-v2-save"
disabled={readOnly || !isDirty || isSaving}
disabled={readOnly || isSaving}
loading={!readOnly && isSaving}
onClick={readOnly ? undefined : onSave}
>

View File

@@ -1,3 +1,4 @@
import type { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
@@ -23,7 +24,9 @@ jest.mock('api/common/logEvent', () => ({
const mockUseIsAIAssistantEnabled = useIsAIAssistantEnabled as jest.Mock;
function renderHeader(): void {
function renderHeader(
props: Partial<ComponentProps<typeof Header>> = {},
): void {
// AppLayout supplies the TooltipProvider in the app; the header is rendered bare here.
render(
<MemoryRouter>
@@ -33,6 +36,7 @@ function renderHeader(): void {
isSaving={false}
onSave={jest.fn()}
onClose={jest.fn()}
{...props}
/>
</TooltipProvider>
</MemoryRouter>,
@@ -66,4 +70,34 @@ describe('PanelEditor Header', () => {
).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
});
it('keeps Save enabled even when there are no unsaved edits', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: false });
expect(screen.getByTestId('panel-editor-v2-save')).toBeEnabled();
});
it('disables Save only while read-only or saving', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: true, readOnly: true, readOnlyReason: 'Locked' });
expect(screen.getByTestId('panel-editor-v2-save')).toBeDisabled();
});
it('shows the Unsaved Changes badge only when there are unsaved edits', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: false });
expect(
screen.queryByTestId('panel-editor-v2-unsaved-badge'),
).not.toBeInTheDocument();
renderHeader({ isDirty: true });
expect(
screen.getByTestId('panel-editor-v2-unsaved-badge'),
).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
@@ -100,6 +101,12 @@ jest.mock('@signozhq/ui/resizable', () => ({
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockShowErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: mockShowErrorModal,
}),
}));
// Children mocked to capture props (and expose a Save trigger / footer slot).
const mockHeaderProps = jest.fn();
@@ -261,13 +268,13 @@ describe('PanelEditorContainer composition', () => {
);
});
it('keeps a query-less new panel unsaveable but still serializes its seed query', () => {
it('keeps a query-less new panel clean but still serializes its seed query', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({ alwaysSerializeQuery: true }),
);
// No query and no edits yet → nothing to save, so Save stays disabled.
// No query and no edits yet → not dirty, so closing won't prompt to discard.
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: false }),
);
@@ -290,7 +297,7 @@ describe('PanelEditorContainer composition', () => {
);
});
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
it('marks a new panel that already has a query dirty (e.g. list auto-runs one)', () => {
const seededQuery = {
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
};
@@ -327,6 +334,24 @@ describe('PanelEditorContainer composition', () => {
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('surfaces a save failure through the error modal', async () => {
// The raw thrown value flows straight to the modal, which normalizes it to an
// APIError itself (that normalization is covered by ErrorModalProvider's tests).
const failure = {
response: {
status: 400,
data: { error: { code: 'INVALID', message: 'Panel name already exists' } },
},
};
mockSave.mockRejectedValueOnce(failure);
setup(makePanel('signoz/TimeSeriesPanel'));
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => expect(mockShowErrorModal).toHaveBeenCalledWith(failure));
expect(toast.error).not.toHaveBeenCalled();
});
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
setup(makePanel('signoz/TimeSeriesPanel'));

View File

@@ -117,6 +117,49 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
},
);
it('a NEW panel stays query-dirty after Stage & Run commits the edited query into the draft', async () => {
// Repro of the P0: new panel → edit → Run commits the query into the draft; the
// dirty baseline must not drift onto it, or Save re-disables.
const editedInUrl: Query = {
...initialQueriesMap[DataSource.METRICS],
id: 'edited-new-panel',
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'edited-legend',
},
],
},
};
const committed = toPerses(editedInUrl, panelType);
const { result, rerender } = renderHook(
({ draftQueries }: { draftQueries: DashboardtypesQueryDTO[] }) =>
usePanelEditorQuerySync({
draft: makePanel(draftQueries),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
alwaysSerializeQuery: true,
signal: DataSource.METRICS as unknown as TelemetrytypesSignalDTO,
// savedQueries omitted — new panel.
}),
{
wrapper: makeUrlWrapper(editedInUrl),
initialProps: { draftQueries: [] as DashboardtypesQueryDTO[] },
},
);
// The edited query (from the URL) diverges from the seeded default → dirty.
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
// Stage & Run commits the edited query into the draft → must stay dirty.
rerender({ draftQueries: committed });
expect(result.current.isQueryDirty).toBe(true);
});
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
// An older saved query carries only a few fields; the builder re-emits many more
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read

View File

@@ -79,6 +79,11 @@ export function usePanelEditorQuerySync({
// in-editor edit in the URL survives a refresh / browser Back-Forward.
useShareBuilderUrl({ defaultValue: seedQuery });
// Frozen seed for the new-panel dirty baseline: Stage & Run commits the live query
// into `draft.spec.queries`, so re-reading `seedQuery` would drift the baseline onto
// the run query and Save would re-disable right after a run.
const initialSeedRef = useRef(seedQuery);
// Commit the live query into the draft (what the preview fetches).
const commitQuery = useCallback(
(query: Query): boolean => {
@@ -152,16 +157,19 @@ export function usePanelEditorQuerySync({
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
// stored query) reading an untouched panel as modified. New panel: fall back to the
// frozen initial seed (see `initialSeedRef`).
const baselineEnvelopes = useMemo(
() =>
toQueryEnvelopes(
toPerses(
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
savedQueries
? fromPerses(savedQueries, panelType)
: initialSeedRef.current,
panelType,
),
),
[savedQueries, seedQuery, panelType],
[savedQueries, panelType],
);
const isQueryDirty = useMemo(
() =>

View File

@@ -19,6 +19,7 @@ import {
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
@@ -159,9 +160,10 @@ function PanelEditorContainer({
return section?.controls;
}, [panelDefinition]);
// New panels are savable once seeded with a query (List auto-seeds one). Read the
// seed `panel`, not the live `draft` — the staged-query sync commits the seed into
// the draft on open, which would falsely dirty an untouched query-less new panel.
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
@@ -226,6 +228,7 @@ function PanelEditorContainer({
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
@@ -236,12 +239,22 @@ function PanelEditorContainer({
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved');
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch {
toast.error('Failed to save panel');
} catch (err) {
showErrorModal(err);
}
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollTargetId, onSaved]);
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,

View File

@@ -51,7 +51,7 @@ describe('panelStatusFromError', () => {
it('falls back to the error message when there is no structured body', () => {
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
code: 'unknown_error',
code: 'UPSTREAM_UNAVAILABLE',
message: 'boom',
docsUrl: undefined,
messages: [],

View File

@@ -12,13 +12,13 @@ import {
deleteDashboardV2,
invalidateListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
import styles from './ActionsPopover.module.scss';
interface Props {

View File

@@ -6,11 +6,11 @@ import { Typography } from '@signozhq/ui/typography';
import { Bookmark, PenLine, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
import type { SavedView } from '../../types';
import { type BuiltinView } from '../../utils/views';
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
import ViewNamePopover from './ViewNamePopover';
import styles from './ViewsRail.module.scss';

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,

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