Compare commits

...

17 Commits

Author SHA1 Message Date
Vinícius Lourenço
4480087ef2 chore(docs): add authz guide 2026-07-28 14:56:49 -03:00
Gaurav Tewari
591837152e fix(warning-popover): background color to l2 & message alignment (#12302)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
build-staging / staging (push) Blocked by required conditions
* 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
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
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
Abhi kumar
33a0cd043c fix(dashboard-v2): stop the table drilldown crashing on a numeric group-by (#12298)
Clicking a numeric group-by column in a V2 table panel replaced the whole
dashboard with "Something went wrong :/".

getGroupContextMenuConfig indexed QUERY_BUILDER_OPERATORS_BY_TYPES with the
group-by's `dataType` and called `.filter` on the result. That map is keyed by
the V3 data types, but a V2 panel's group-by carries the V5 `fieldDataType`,
and the backend stores every `float64` as `number` — a key the map has never
had. The lookup returned undefined, `.filter` threw during render, and the
page-level error boundary swallowed the dashboard.

The same gap made isNumberDataType miss `number`, so the filter value was sent
as a quoted string instead of a number.

Resolve the operator list through a table that knows `number`, falling back to
the string set so an unmapped type can't white-screen a panel, and treat
`number` as numeric. No query payload changes.
2026-07-27 18:02:18 +00:00
Swapnil Nakade
1a6a693466 refactor(cloud-integrations): remove cloud_integration_id query param (#12300) 2026-07-27 17:57:09 +00:00
Abhi kumar
3e35d1ef64 fix(dashboards-v2): new panel reads clean on open, not dirty (#12297)
The panel-editor dirty check trips a new panel the instant it opens: an
untouched, query-less new panel (e.g. Time Series) showed Save enabled and a
discard-changes prompt with no user edit.

The isDirty third clause `(isNew && draft.spec.queries.length > 0)` gates a new
panel as savable once it carries a query — List auto-seeds one, other kinds open
query-less. It read the live `draft`, but the staged-query sync (added in
f514af469e) commits the builder-seeded query into `draft.spec.queries` on open
for every kind, so the clause tripped even for a query-less panel.

Read the immutable seed `panel.spec.queries` instead — `[]` for non-List (clean
on open), a real query for List / explorer-exports (savable on open). Genuine
edits still surface via isSpecDirty / isQueryDirty.

- PanelEditorContainer test: regression case where the staged sync populated the
  draft but the seed is query-less; the container mock previously returned
  draft === panel, so this class of bug slipped past coverage.
- Query-sync integration tests: a new panel is not query-dirty on mount across
  signals and List.
2026-07-27 17:40:01 +00:00
Aditya Singh
1483fbd2c5 fix(query-builder): coerce table sort values to string to avoid localeCompare crash (#12289)
The QueryTable column sorter called `.localeCompare` on values cast with
`as string`, which is compile-time only. Upstream `processTableRowValue`
normalizes numeric-looking strings to real numbers while null becomes the
string "N/A", so a grouped column can mix numbers and text. Comparing a
number cell against a text cell skipped the numeric branch and invoked
`(200).localeCompare(...)`, throwing "localeCompare is not a function" and
crashing the table on sort.

Extract the compare logic into `compareTableColumnValues` and coerce both
sides with `String(x ?? '')` so any value type sorts safely while
preserving empty-string semantics for null/undefined.

Affects all QueryTable surfaces: Traces/Logs table views, APM Top
Operations, and dashboard Table panels.

Fixes SIGNOZ-UI-5GC
2026-07-27 16:00:29 +00:00
Vikrant Gupta
1255637e25 fix(authz): changelog column type for postgres metastore (#12299) 2026-07-27 15:52:06 +00:00
Naman Verma
38639847be fix: clamp over-wide widgets, drop zero-width widgets, and properly handle invalid order by (#12295) 2026-07-27 15:48:18 +00:00
Aditya Singh
31cb4d7520 feat(logs): unwrap lone message field from json log body (#12206)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-27 13:31:56 +00:00
Ashwin Bhatkal
5ede27e8fb fix(ai-assistant): page context for the V2 panel editor (edit + create) and the v2 dashboards list in the picker (#12291)
* fix(ai-assistant): add page context for the V2 panel editor route

The V2 panel editor lives on `/dashboard/:dashboardId/panel/:panelId`,
which `getAutoContexts` never matched — the three existing dashboard
matchers are all `exact`, so a 4-segment URL fell through every branch
and the assistant opened with no context chip and a page type of
`other`.

A saved panel maps to `panel_edit` with its id. The unsaved `panel/new`
route has no id to attach yet and the schema requires a non-empty
`panel_edit.widgetId`, so it degrades to the dashboard context; the
in-progress query and time range still ride along in shared metadata.

* fix(ai-assistant): use the v2 dashboards list in the context picker

`GET /api/v1/dashboards` is now a stub that returns a V1-deprecated
error, and `useGetAllDashboard` routes errors through `useErrorModal` —
so opening the picker on the Dashboards tab both showed "Failed to load
dashboards" and popped a global error modal.

Switch to `useListDashboardsForUserV2`, the same source the V2 list page
and export picker already use. `resolveAutoContextName` reads the v2
query key from cache too, so auto-context chips resolve to real
dashboard titles again instead of falling back to a generic label.

The assistant was the last live caller of the v1 list: the only other
one, `container/ListOfDashboard`, is unreachable now that the routed
`pages/DashboardsListPage` renders the V2 page.

* fix(ai-assistant): report panel_create on the unsaved panel editor

`/dashboard/:id/panel/new` previously reported `dashboard_detail`, so the
assistant was told the user was looking at a dashboard rather than
creating a panel. It now reports `panel_create`, which renders a "New
panel" chip.

`PageTypeDTO` has no `panel_create` member — `alert_new` is the
precedent it's missing — so `resolvePageType` maps it to `panel_edit`
until the backend adds one. An unmapped key would fall through to
`other` and lose the empty-state chips. The context itself carries no
`widgetId`, which the schema only requires when `metadata.page` is
literally `panel_edit`.

Also drops the `DASHBOARD_WIDGET` matcher. That V1 route is unreachable
— nothing generates a link to it since V2 always builds panel-editor
paths — and it read the `:widgetId` path segment, which V1's own page
ignored in favour of `?widgetId=`, so its `widgetId` was never right.
2026-07-27 13:29:40 +00:00
81 changed files with 2811 additions and 435 deletions

View File

@@ -9934,14 +9934,9 @@ paths:
get:
deprecated: false
description: This endpoint lists the services metadata for the specified cloud
provider
provider, without any account context.
operationId: ListServicesMetadata
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -9991,14 +9986,10 @@ paths:
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
get:
deprecated: false
description: This endpoint gets a service for the specified cloud provider
description: This endpoint gets a service definition for the specified cloud
provider, without any account context.
operationId: GetService
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 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

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

@@ -36,14 +36,12 @@ import type {
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccountServicesMetadata200,
ListAccountServicesMetadataPathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
ListServicesMetadataParams,
ListServicesMetadataPathParameters,
RenderErrorResponseDTO,
UpdateAccountPathParameters,
@@ -1162,30 +1160,24 @@ export const invalidateGetConnectionCredentials = async (
};
/**
* This endpoint lists the services metadata for the specified cloud provider
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
* @summary List services metadata
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
params,
signal,
});
};
export const getListServicesMetadataQueryKey = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services`,
...(params ? [params] : []),
] as const;
export const getListServicesMetadataQueryKey = ({
cloudProvider,
}: ListServicesMetadataPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
};
export const getListServicesMetadataQueryOptions = <
@@ -1193,7 +1185,6 @@ export const getListServicesMetadataQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1205,12 +1196,11 @@ export const getListServicesMetadataQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListServicesMetadataQueryKey({ cloudProvider }, params);
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
return {
queryKey,
@@ -1238,7 +1228,6 @@ export function useListServicesMetadata<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1249,7 +1238,6 @@ export function useListServicesMetadata<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
params,
options,
);
@@ -1266,11 +1254,10 @@ export function useListServicesMetadata<
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
options,
);
@@ -1278,29 +1265,26 @@ export const invalidateListServicesMetadata = async (
};
/**
* This endpoint gets a service for the specified cloud provider
* This endpoint gets a service definition for the specified cloud provider, without any account context.
* @summary Get service
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
params,
signal,
});
};
export const getGetServiceQueryKey = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
) => {
export const getGetServiceQueryKey = ({
cloudProvider,
serviceId,
}: GetServicePathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
...(params ? [params] : []),
] as const;
};
@@ -1309,7 +1293,6 @@ export const getGetServiceQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1321,12 +1304,11 @@ export const getGetServiceQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, params, signal);
}) => getService({ cloudProvider, serviceId }, signal);
return {
queryKey,
@@ -1352,7 +1334,6 @@ export function useGetService<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1363,7 +1344,6 @@ export function useGetService<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
params,
options,
);
@@ -1380,11 +1360,10 @@ export function useGetService<
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
options,
);

View File

@@ -10204,14 +10204,6 @@ export type GetConnectionCredentials200 = {
export type ListServicesMetadataPathParameters = {
cloudProvider: string;
};
export type ListServicesMetadataParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type ListServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
@@ -10224,14 +10216,6 @@ export type GetServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type GetServiceParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type GetService200 = {
data: CloudintegrationtypesServiceDTO;
/**

View File

@@ -380,4 +380,88 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
describe('raw logs body: extract lone `message` field', () => {
function makeRawResult(
rows: Array<{ timestamp: string; data: Record<string, any> }>,
type: 'raw' | 'trace' = 'raw',
): ReturnType<typeof convertV5ResponseToLegacy> {
const v5Data = {
type,
data: { results: [{ queryName: 'A', rows }] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
} as unknown as QueryRangeResponseV5;
const params = makeBaseParams(type as RequestType, [
{
type: 'builder_query',
spec: {
name: 'A',
signal: type === 'trace' ? 'traces' : 'logs',
stepInterval: 60,
disabled: false,
aggregations: [],
},
},
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
return convertV5ResponseToLegacy(input, { A: 'A' }, false);
}
it('unwraps body when it is an object with only a message field', () => {
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: 'hello' } } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe('hello');
});
it('leaves body unchanged when the object has keys besides message', () => {
const body = { message: 'hello', level: 'INFO' };
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toStrictEqual(
body,
);
});
it('leaves a string body unchanged (use_json_body off)', () => {
const result = makeRawResult([
{
timestamp: '2026-07-21T00:00:00Z',
data: { body: '{"message":"hello"}' },
},
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
'{"message":"hello"}',
);
});
it('stringifies the nested object when message is an object', () => {
const nested = { a: 1, b: 2 };
const result = makeRawResult([
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: nested } } },
]);
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
JSON.stringify(nested),
);
});
it('does not add a body key to rows without a body (traces)', () => {
const result = makeRawResult(
[{ timestamp: '2026-07-21T00:00:00Z', data: { name: 'span-1' } }],
'trace',
);
const data = (result.payload.data.result[0].list?.[0] as any)?.data ?? {};
expect('body' in data).toBe(false);
});
});
});

View File

@@ -273,6 +273,19 @@ function convertScalarWithFormatForWeb(
});
}
function extractOnlyMessageBody(body: unknown): unknown {
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
if (isJsonBody) {
const keys = Object.keys(body);
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
if (hasOnlyMessageKey) {
const { message } = body as { message: unknown };
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
return body;
}
/**
* Converts V5 RawData to legacy format
*/
@@ -285,14 +298,22 @@ function convertRawData(
queryName: rawData.queryName,
legend: legendMap[rawData.queryName] || rawData.queryName,
series: null,
list: rawData.rows?.map((row) => ({
timestamp: row.timestamp,
data: {
list: rawData.rows?.map((row) => {
const data = {
// Map raw data to ILog structure - spread row.data first to include all properties
...row.data,
date: row.timestamp,
} as any,
})),
} as any;
if ('body' in row.data) {
data.body = extractOnlyMessageBody(row.data.body);
}
return {
timestamp: row.timestamp,
data,
};
}),
nextCursor: rawData.nextCursor,
};
}

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

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

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

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

@@ -167,6 +167,56 @@ describe('getAutoContexts', () => {
]);
});
it('returns panel edit context on the V2 panel editor', () => {
const dashboardId = 'dash-123';
const panelId = 'panel-abc';
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
dashboardId,
).replace(':panelId', panelId);
const contexts = getAutoContexts(pathname, '');
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: {
page: 'panel_edit',
widgetId: panelId,
},
},
]);
});
it('returns new panel context on the unsaved new-panel editor', () => {
const dashboardId = 'dash-123';
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
dashboardId,
).replace(':panelId', 'new');
const startTime = '1700000000000';
const endTime = '1700003600000';
const contexts = getAutoContexts(
pathname,
`?panelKind=TimeSeries&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`,
);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: {
page: 'panel_create',
timeRange: { start: Number(startTime), end: Number(endTime) },
},
},
]);
});
it('returns empty array on alert overview without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');

View File

@@ -17,6 +17,28 @@ describe('resolvePageType', () => {
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.dashboard_detail);
});
it('returns panel_edit on the V2 panel editor', () => {
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
'dash-123',
).replace(':panelId', 'panel-abc');
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.panel_edit);
});
// `panel_create` has no PageTypeDTO counterpart yet, so it reports
// `panel_edit` rather than degrading to `other`.
it('returns panel_edit on the unsaved new-panel editor', () => {
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
'dash-123',
).replace(':panelId', 'new');
expect(resolvePageType(pathname, '?panelKind=TimeSeries')).toBe(
PageTypeDTO.panel_edit,
);
});
it('returns alerts_triggered on alert history without ruleId', () => {
expect(resolvePageType(ROUTES.ALERT_HISTORY, '')).toBe(
PageTypeDTO.alerts_triggered,

View File

@@ -16,17 +16,22 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import getSessionStorage from 'api/browser/sessionstorage/get';
import setSessionStorage from 'api/browser/sessionstorage/set';
import {
getListDashboardsForUserV2QueryKey,
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRules200,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useQueryService } from 'hooks/useQueryService';
import type { SuccessResponseV2 } from 'types/api';
import type { Dashboard } from 'types/api/dashboard/getAll';
// eslint-disable-next-line
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -100,6 +105,8 @@ function autoContextLabel(ctx: MessageContext): string {
return 'Current dashboard';
case 'panel_edit':
return 'Editing panel';
case 'panel_create':
return 'New panel';
case 'panel_fullscreen':
return 'Panel (fullscreen)';
case 'dashboard_list':
@@ -164,6 +171,18 @@ const TEXTAREA_MAX_HEIGHT_PX = 200;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
/**
* The picker filters client-side, so it pulls one large page instead of
* paginating. Shared with `getQueryData` below — the params are part of the
* generated query key, so both sides must use the same object.
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
return dashboard.spec.display?.name || dashboard.name || 'Untitled';
}
interface SelectedContextItem {
category: ContextCategory;
@@ -716,9 +735,11 @@ export default function ChatInput({
data: dashboardsResponse,
isLoading: isDashboardsLoading,
isError: isDashboardsError,
} = useGetAllDashboard({
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
} = useListDashboardsForUserV2(DASHBOARD_LIST_PARAMS, {
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
},
});
const {
@@ -765,12 +786,12 @@ export default function ChatInput({
return ctx.resourceId;
}
if (ctx.type === 'dashboard' && ctx.resourceId) {
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
const cached = queryClient.getQueryData<ListDashboardsForUserV2200>(
getListDashboardsForUserV2QueryKey(DASHBOARD_LIST_PARAMS),
);
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
if (dash?.data.title) {
return dash.data.title;
const dash = cached?.data?.dashboards?.find((d) => d.id === ctx.resourceId);
if (dash) {
return dashboardTitle(dash);
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
@@ -800,9 +821,9 @@ export default function ChatInput({
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
{
Dashboards:
dashboardsResponse?.data?.map((dashboard) => ({
dashboardsResponse?.data?.dashboards?.map((dashboard) => ({
id: dashboard.id,
value: dashboard.data.title ?? 'Untitled',
value: dashboardTitle(dashboard),
})) ?? [],
Alerts:
alertsResponse?.data

View File

@@ -2,12 +2,13 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The prefill flow only depends on the context-picker data hooks resolving to
// empty lists (so the empty state renders) — mock them to skip real fetches.
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('api/generated/services/rules', () => ({

View File

@@ -2,6 +2,7 @@ import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AlertListTabs } from 'pages/AlertList/types';
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import { matchPath } from 'react-router-dom';
/**
@@ -30,22 +31,23 @@ export function getAutoContexts(
// ── Dashboards ────────────────────────────────────────────────────────────
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
// Panel editor (V2). `panel/new` has no widget id yet and the schema requires
// a non-empty `panel_edit.widgetId`, so it reports `panel_create` instead.
const panelEditorMatch = matchPath<{ dashboardId: string; panelId: string }>(
pathname,
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
);
if (widgetMatch) {
if (panelEditorMatch) {
const { dashboardId, panelId } = panelEditorMatch.params;
const isNewPanel = panelId === NEW_PANEL_ID;
return [
{
source: 'auto',
type: 'dashboard',
resourceId: widgetMatch.params.dashboardId,
metadata: {
page: 'panel_edit',
widgetId: widgetMatch.params.widgetId,
...sharedMetadata,
},
resourceId: dashboardId,
metadata: isNewPanel
? { page: 'panel_create', ...sharedMetadata }
: { page: 'panel_edit', widgetId: panelId, ...sharedMetadata },
},
];
}

View File

@@ -9,6 +9,8 @@ const PAGE_METADATA_TO_DTO: Record<string, PageTypeDTO> = {
dashboard_detail: PageTypeDTO.dashboard_detail,
dashboard_list: PageTypeDTO.dashboard_list,
panel_edit: PageTypeDTO.panel_edit,
// There is no panel_create, so sending panel_edit temporarily
panel_create: PageTypeDTO.panel_edit,
panel_fullscreen: PageTypeDTO.panel_fullscreen,
logs_explorer: PageTypeDTO.logs_explorer,
trace_detail: PageTypeDTO.trace_detail,

View File

@@ -147,7 +147,6 @@ function ServiceDetails({
cloudProvider: type,
serviceId: serviceId || '',
},
undefined,
{
query: {
enabled: !!serviceId && !cloudAccountId,

View File

@@ -39,9 +39,12 @@ function ServicesList({
const {
data: providerServicesMetadata,
isLoading: isProviderServicesLoading,
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
});
} = useListServicesMetadata(
{ cloudProvider: type },
{
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
},
);
const servicesMetadata = hasConnectedAccounts
? accountServicesMetadata

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

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

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

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

@@ -0,0 +1,82 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ClickedData } from 'periscope/components/ContextMenu';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getGroupContextMenuConfig } from '../contextConfig';
const GROUP_KEY = 'http.status_code';
const makeQuery = (dataType: string): Query =>
({
builder: {
queryData: [
{
queryName: 'A',
groupBy: [{ key: GROUP_KEY, dataType, type: 'attribute' }],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
}) as unknown as Query;
const clickedData: ClickedData = {
column: { dataIndex: GROUP_KEY },
record: { key: GROUP_KEY, timestamp: 0 },
};
const renderGroupMenu = (query: Query): void => {
const { items } = getGroupContextMenuConfig({
query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: jest.fn(),
});
render(<div>{items}</div>);
};
describe('getGroupContextMenuConfig', () => {
// A `number` group-by used to throw: it isn't a key of QUERY_BUILDER_OPERATORS_BY_TYPES.
it('renders comparison operators for a `number` group-by column', () => {
renderGroupMenu(makeQuery('number'));
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
expect(screen.getByText('Is less than')).toBeInTheDocument();
});
it('renders only equality operators for a string group-by column', () => {
renderGroupMenu(makeQuery(DataTypes.String));
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(screen.getByText('Is not this')).toBeInTheDocument();
expect(
screen.queryByText('Is greater than or equal to'),
).not.toBeInTheDocument();
});
it('falls back to equality operators when the column has no known data type', () => {
renderGroupMenu(makeQuery(''));
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(
screen.queryByText('Is greater than or equal to'),
).not.toBeInTheDocument();
});
it('returns no items for a non-table panel', () => {
const config = getGroupContextMenuConfig({
query: makeQuery('number'),
clickedData,
panelType: PANEL_TYPES.TIME_SERIES,
onColumnClick: jest.fn(),
});
expect(config.items).toBeUndefined();
});
});

View File

@@ -1,8 +1,11 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
getOperatorsByDataType,
getQueryData,
getViewQuery,
isNumberDataType,
isValidQueryName,
} from '../drilldownUtils';
import { METRIC_TO_LOGS_TRACES_MAPPINGS } from '../metricsCorrelationUtils';
@@ -687,4 +690,41 @@ describe('drilldownUtils', () => {
expect(expr).toContain(`name = 'GET /api'`);
});
});
describe('getOperatorsByDataType', () => {
it('gives numeric operators for the V5 `number` data type', () => {
const operators = getOperatorsByDataType('number');
expect(operators).toContain('>=');
expect(operators).toContain('<');
expect(operators).not.toContain('LIKE');
});
it('gives numeric operators for the V3 int64 / float64 data types', () => {
expect(getOperatorsByDataType(DataTypes.Int64)).toContain('>=');
expect(getOperatorsByDataType(DataTypes.Float64)).toContain('>=');
});
it('falls back to the string operators for unmapped, empty and missing types', () => {
const stringOperators = getOperatorsByDataType(DataTypes.String);
expect(getOperatorsByDataType('[]string')).toStrictEqual(stringOperators);
expect(getOperatorsByDataType('')).toStrictEqual(stringOperators);
expect(getOperatorsByDataType(undefined)).toStrictEqual(stringOperators);
});
});
describe('isNumberDataType', () => {
it.each([DataTypes.Int64, DataTypes.Float64, 'number' as DataTypes])(
'treats %s as numeric',
(dataType) => {
expect(isNumberDataType(dataType)).toBe(true);
},
);
it.each([DataTypes.String, DataTypes.bool, DataTypes.EMPTY, undefined])(
'does not treat %s as numeric',
(dataType) => {
expect(isNumberDataType(dataType)).toBe(false);
},
);
});
});

View File

@@ -0,0 +1,111 @@
import { render, screen } from '@testing-library/react';
import {
DashboardtypesQueryDTO,
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fromPerses,
toPerses,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { ClickedData } from 'periscope/components/ContextMenu';
import { getGroupContextMenuConfig } from '../contextConfig';
import {
addFilterToQuery,
getBaseMeta,
isNumberDataType,
} from '../drilldownUtils';
const GROUP_KEY = 'panja.pinger.status_code';
/**
* A saved V2 table panel grouped by a numeric attribute. The backend rewrites `float64` to
* `number` when it stores the panel, so `number` is what a reload actually carries.
*/
const savedPanelQueries = [
{
kind: 'signoz/scalar',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
aggregations: [{ metricName: 'probe_checks', spaceAggregation: 'sum' }],
groupBy: [
{
name: GROUP_KEY,
fieldDataType: 'number',
fieldContext: 'attribute',
},
],
filter: { expression: '' },
disabled: false,
},
},
],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
describe('drilldown on a numeric group-by column (V2 table panel)', () => {
const v1Query = fromPerses(savedPanelQueries, PANEL_TYPES.TABLE);
const groupByDataType = getBaseMeta(v1Query, GROUP_KEY)?.dataType;
it('sees the `number` type the panel was stored with', () => {
expect(groupByDataType).toBe('number');
});
it('offers the numeric operators instead of throwing', () => {
const clickedData: ClickedData = {
column: { dataIndex: GROUP_KEY },
record: { key: GROUP_KEY, timestamp: 0 },
};
const { items } = getGroupContextMenuConfig({
query: v1Query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: jest.fn(),
});
render(<div>{items}</div>);
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
expect(screen.getByText('Is this')).toBeInTheDocument();
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
});
it('filters on an unquoted number', () => {
// What the filter hooks branch on before coercing the clicked value.
expect(isNumberDataType(groupByDataType)).toBe(true);
const refined = addFilterToQuery(v1Query, [
{ filterKey: GROUP_KEY, filterValue: 200, operator: '=' },
]);
expect(refined.builder.queryData[0].filter?.expression).toBe(
`${GROUP_KEY} = 200`,
);
});
it('leaves the stored query shape untouched on the way back out', () => {
const [envelope] = toPerses(v1Query, PANEL_TYPES.TABLE);
const composite = envelope.spec.plugin
.spec as Querybuildertypesv5CompositeQueryDTO;
const [query] = composite.queries ?? [];
// The generated envelope union doesn't discriminate `spec` by `type`.
const spec = query?.spec as Querybuildertypesv5BuilderQuerySpecDTO;
expect(spec.groupBy?.[0]).toMatchObject({
name: GROUP_KEY,
fieldDataType: 'number',
fieldContext: 'attribute',
});
});
});

View File

@@ -1,12 +1,9 @@
import { ReactNode } from 'react';
import {
PANEL_TYPES,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBaseMeta } from './drilldownUtils';
import { getBaseMeta, getOperatorsByDataType } from './drilldownUtils';
import { SUPPORTED_OPERATORS } from './menuOptions';
import { BreakoutAttributeType } from './types';
@@ -49,15 +46,9 @@ export function getGroupContextMenuConfig({
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
const filterKey = clickedData?.column?.dataIndex;
const filterDataType =
getBaseMeta(query, filterKey as string)?.dataType || 'string';
const filterDataType = getBaseMeta(query, filterKey as string)?.dataType;
const operators =
QUERY_BUILDER_OPERATORS_BY_TYPES[
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
];
const filterOperators = operators.filter(
const filterOperators = getOperatorsByDataType(filterDataType).filter(
(operator) => SUPPORTED_OPERATORS[operator],
);

View File

@@ -3,6 +3,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
import {
initialQueryBuilderFormValuesMap,
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { isApmMetric } from 'container/PanelWrapper/utils';
@@ -54,13 +55,44 @@ export const getRoute = (key: string): string => {
}
};
/**
* A group-by's `dataType` comes from the field metadata, which reports a numeric as `number`
* (V5, what a saved V2 panel carries) or as `int64` / `float64` (V3) — all three are numeric.
*/
const NUMERIC_DATA_TYPES: string[] = [
DataTypes.Int64,
DataTypes.Float64,
'number',
];
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
if (!dataType) {
return false;
}
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
return NUMERIC_DATA_TYPES.includes(dataType);
};
/**
* `QUERY_BUILDER_OPERATORS_BY_TYPES` is keyed by the V3 data types, so the `number` a saved V2
* panel carries misses it — and a raw lookup returns `undefined`, which throws on the caller's
* `.filter`. Resolve through this table and fall back to the string set.
*/
const OPERATOR_DATA_TYPES: Record<
string,
keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
> = {
[DataTypes.String]: 'string',
[DataTypes.Int64]: 'int64',
[DataTypes.Float64]: 'float64',
[DataTypes.bool]: 'bool',
number: 'float64',
};
export const getOperatorsByDataType = (dataType?: string): string[] =>
QUERY_BUILDER_OPERATORS_BY_TYPES[
OPERATOR_DATA_TYPES[dataType ?? ''] ?? 'string'
];
export interface FilterData {
filterKey: string;
filterValue: string | number;

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

@@ -0,0 +1,94 @@
import {
compareTableColumnValues,
RowData,
} from '../createTableColumnsFromQuery';
// Builds a minimal RowData row. Values are intentionally loosely typed because
// real query responses can put objects/arrays into cells despite RowData's
// declared `string | number` index signature (that mismatch is the bug under test).
const row = (value: unknown, dataIndex = 'col'): RowData =>
({
timestamp: 0,
key: 'k',
[dataIndex]: value,
}) as unknown as RowData;
describe('compareTableColumnValues', () => {
it('sorts numerically when both cells are numbers', () => {
expect(compareTableColumnValues(row(1), row(2), 'col')).toBeLessThan(0);
expect(compareTableColumnValues(row(2), row(1), 'col')).toBeGreaterThan(0);
expect(compareTableColumnValues(row(5), row(5), 'col')).toBe(0);
});
it('sorts numeric-looking strings numerically, not lexically', () => {
// "10" vs "9": numeric branch => 10 - 9 > 0. A string compare would be < 0.
expect(compareTableColumnValues(row('10'), row('9'), 'col')).toBeGreaterThan(
0,
);
});
it('prefers the `<dataIndex>_without_unit` value for numeric comparison', () => {
const a = row('2 ms');
const b = row('10 ms');
a.col_without_unit = 2;
b.col_without_unit = 10;
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
});
it('falls back to locale string compare for non-numeric strings', () => {
expect(compareTableColumnValues(row('abc'), row('abd'), 'col')).toBe(
'abc'.localeCompare('abd'),
);
});
it('treats null/undefined cells as empty string (no "null"/"undefined")', () => {
// Empty string sorts before a real word, and two empties are equal.
expect(compareTableColumnValues(row(null), row('a'), 'col')).toBeLessThan(0);
expect(compareTableColumnValues(row(undefined), row(undefined), 'col')).toBe(
0,
);
// If null coerced to the literal "null", this would sort after "a".
expect(
compareTableColumnValues(row(null), row('a'), 'col'),
).not.toBeGreaterThan(0);
});
it('does not throw when a cell value is an object', () => {
const a = row({ foo: 'bar' });
const b = row({ foo: 'baz' });
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
});
it('does not throw when a cell value is an array', () => {
const a = row([1, 2]);
const b = row([3, 4]);
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
// String([1,2]) === "1,2" < String([3,4]) === "3,4"
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
});
it('does not throw when one cell is numeric and the other is an object', () => {
const a = row(42);
const b = row({ foo: 'bar' });
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
});
it('does not throw for a number cell compared against an "N/A" cell', () => {
// http.status_code column: [null, "200", ...] -> ["N/A", 200, ...]
const numberCell = row(200); // numeric string "200" becomes the number 200
const naCell = row('N/A'); // null becomes the string "N/A"
// Both orderings — antd's sorter compares pairs in both directions.
expect(() =>
compareTableColumnValues(numberCell, naCell, 'col'),
).not.toThrow();
expect(() =>
compareTableColumnValues(naCell, numberCell, 'col'),
).not.toThrow();
expect(typeof compareTableColumnValues(numberCell, naCell, 'col')).toBe(
'number',
);
});
});

View File

@@ -637,6 +637,21 @@ const generateData = (
return data;
};
export const compareTableColumnValues = (
a: RowData,
b: RowData,
dataIndex: string,
): number => {
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
if (!isNaN(valueA) && !isNaN(valueB)) {
return valueA - valueB;
}
return String(a[dataIndex] ?? '').localeCompare(String(b[dataIndex] ?? ''));
};
const generateTableColumns = (
dynamicColumns: DynamicColumns,
renderColumnCell?: QueryTableProps['renderColumnCell'],
@@ -650,18 +665,8 @@ const generateTableColumns = (
title: item.title,
width: QUERY_TABLE_CONFIG.width,
render: renderColumnCell && renderColumnCell[dataIndex],
sorter: (a: RowData, b: RowData): number => {
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
if (!isNaN(valueA) && !isNaN(valueB)) {
return valueA - valueB;
}
return ((a[dataIndex] as string) || '').localeCompare(
(b[dataIndex] as string) || '',
);
},
sorter: (a: RowData, b: RowData): number =>
compareTableColumnValues(a, b, dataIndex),
};
return [...acc, column];

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();
@@ -174,11 +181,13 @@ const baseProps = {
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
draftOverrides?: { isSpecDirty?: boolean },
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
): void {
// The live draft can diverge from the seed `panel`; default to the seed.
const draftPanel = draftOverrides?.draft ?? panel;
mockUseDraft.mockReturnValue({
draft: panel,
spec: panel.spec,
draft: draftPanel,
spec: draftPanel.spec,
setSpec: mockSetSpec,
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
});
@@ -259,19 +268,36 @@ 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 }),
);
});
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
it('keeps a query-less new panel clean after the staged-query sync seeds its draft (regression)', () => {
// Staged-query sync populated the draft with the seed; dirty must read the seed
// `panel` (query-less), not the draft, else an untouched new panel reads dirty.
const committedDraft = makePanel('signoz/TimeSeriesPanel', [
{ spec: { plugin: { kind: 'signoz/CompositeQuery', spec: {} } } },
]);
setup(
makePanel('signoz/TimeSeriesPanel'),
{ isNew: true },
{ draft: committedDraft },
);
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: false }),
);
});
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' } } },
};
@@ -308,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

@@ -2,6 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -89,6 +90,76 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
expect(result.current.isQueryDirty).toBe(false);
});
it.each([
[DataSource.METRICS, PANEL_TYPES.TIME_SERIES],
[DataSource.LOGS, PANEL_TYPES.TIME_SERIES],
[DataSource.TRACES, PANEL_TYPES.TIME_SERIES],
[DataSource.LOGS, PANEL_TYPES.LIST],
])(
'a NEW %s panel (%s, no savedQueries) is NOT query-dirty on mount',
async (ds, pt) => {
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel([]),
panelType: pt,
setSpec: jest.fn(),
refetch: jest.fn(),
alwaysSerializeQuery: true,
signal: ds as unknown as TelemetrytypesSignalDTO,
// savedQueries omitted — new panel.
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
},
);
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,11 +160,13 @@ function PanelEditorContainer({
return section?.controls;
}, [panelDefinition]);
// A new panel is savable once it has a query to run — List auto-seeds one; other
// kinds open query-less, so there's nothing to save until the user builds one.
// 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 && draft.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
@@ -225,6 +228,7 @@ function PanelEditorContainer({
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
@@ -235,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,4 +1,4 @@
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { FieldDataType } from 'types/api/v5/queryRange';
export interface QueryKeyDataSuggestionsProps {
label: string;
@@ -7,7 +7,12 @@ export interface QueryKeyDataSuggestionsProps {
apply?: string;
detail?: string;
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span';
fieldDataType?: QUERY_BUILDER_KEY_TYPES;
/**
* The field's type as the API reports it. Was declared as the antlr key-type enum
* (`string | number | boolean`), which the endpoint never returns — every consumer cast it
* back to `FieldDataType`.
*/
fieldDataType?: FieldDataType;
name: string;
signal: 'traces' | 'logs' | 'metrics';
}
@@ -26,7 +31,7 @@ export interface QueryKeyRequestProps {
signal: 'traces' | 'logs' | 'metrics';
searchText: string;
fieldContext?: 'resource' | 'scope' | 'attribute' | 'span';
fieldDataType?: QUERY_BUILDER_KEY_TYPES;
fieldDataType?: FieldDataType;
metricName?: string;
metricNamespace?: string;
signalSource?: 'meter' | '';

View File

@@ -0,0 +1,34 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { FieldDataType } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from '../fieldDataType';
describe('fieldDataTypeToDataType', () => {
it('maps the reported numeric spellings onto the query-builder numerics', () => {
expect(fieldDataTypeToDataType('number')).toBe(DataTypes.Float64);
expect(fieldDataTypeToDataType('int64')).toBe(DataTypes.Int64);
expect(fieldDataTypeToDataType('float64')).toBe(DataTypes.Float64);
});
it('maps the list spellings onto the array types', () => {
expect(fieldDataTypeToDataType('[]string')).toBe(DataTypes.ArrayString);
expect(fieldDataTypeToDataType('[]bool')).toBe(DataTypes.ArrayBool);
expect(fieldDataTypeToDataType('[]int64')).toBe(DataTypes.ArrayInt64);
expect(fieldDataTypeToDataType('[]float64')).toBe(DataTypes.ArrayFloat64);
expect(fieldDataTypeToDataType('[]number')).toBe(DataTypes.ArrayFloat64);
});
it('passes through the spellings the two vocabularies share', () => {
expect(fieldDataTypeToDataType('string')).toBe(DataTypes.String);
expect(fieldDataTypeToDataType('bool')).toBe(DataTypes.bool);
});
it('returns EMPTY for empty, missing and not-yet-known types', () => {
expect(fieldDataTypeToDataType('')).toBe(DataTypes.EMPTY);
expect(fieldDataTypeToDataType(undefined)).toBe(DataTypes.EMPTY);
// The backend can store `[]json` / `[]dynamic`, which the API union doesn't list.
expect(fieldDataTypeToDataType('[]json' as FieldDataType)).toBe(
DataTypes.EMPTY,
);
});
});

View File

@@ -42,7 +42,12 @@ export function toAPIError(
try {
ErrorResponseHandlerForGeneratedAPIs(error);
} catch (apiError) {
if (apiError instanceof APIError) {
// UPSTREAM_UNAVAILABLE means the handler couldn't find a backend error code
// (non-envelope body); prefer the caller's context-specific defaultMessage.
if (
apiError instanceof APIError &&
apiError.getErrorCode() !== 'UPSTREAM_UNAVAILABLE'
) {
return apiError;
}
}

View File

@@ -0,0 +1,31 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { FieldDataType } from 'types/api/v5/queryRange';
/**
* The field metadata APIs and the query builder spell field types differently: the metadata
* collapses the numerics to `number` and writes lists as `[]string`, while `DataTypes` — which
* keys the operator and attribute-value lookups — uses `float64` and `array(string)`. Convert
* where a reported type becomes a query-builder one, so those lookups can't miss.
*/
const TO_DATA_TYPE: Record<FieldDataType, DataTypes> = {
'': DataTypes.EMPTY,
string: DataTypes.String,
bool: DataTypes.bool,
int64: DataTypes.Int64,
float64: DataTypes.Float64,
number: DataTypes.Float64,
'[]string': DataTypes.ArrayString,
'[]bool': DataTypes.ArrayBool,
'[]int64': DataTypes.ArrayInt64,
'[]float64': DataTypes.ArrayFloat64,
'[]number': DataTypes.ArrayFloat64,
};
/**
* Exhaustive over `FieldDataType`, so a type added to the API union fails to compile here
* rather than going missing at runtime. The fallback is for a spelling the union doesn't know
* yet — the backend can store `[]json` and `[]dynamic` — not for any of the cases above.
*/
export const fieldDataTypeToDataType = (
fieldDataType?: FieldDataType,
): DataTypes => TO_DATA_TYPE[fieldDataType ?? ''] ?? DataTypes.EMPTY;

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.7
require (
dario.cat/mergo v1.0.2
github.com/AfterShip/clickhouse-sql-parser v0.4.16
github.com/AfterShip/clickhouse-sql-parser v0.5.2
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0

4
go.sum
View File

@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AfterShip/clickhouse-sql-parser v0.4.16 h1:gpl+wXclYUKT0p4+gBq22XeRYWwEoZ9f35vogqMvkLQ=
github.com/AfterShip/clickhouse-sql-parser v0.4.16/go.mod h1:W0Z82wJWkJxz2RVun/RMwxue3g7ut47Xxl+SFqdJGus=
github.com/AfterShip/clickhouse-sql-parser v0.5.2 h1:KCxdmvuVawVF4yl0ZS33q7olgmLZwvZG5BjWHp6o414=
github.com/AfterShip/clickhouse-sql-parser v0.5.2/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=

View File

@@ -136,9 +136,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
ID: "ListServicesMetadata",
Tags: []string{"cloudintegration"},
Summary: "List services metadata",
Description: "This endpoint lists the services metadata for the specified cloud provider",
Description: "This endpoint lists the services metadata for the specified cloud provider, without any account context.",
Request: nil,
RequestQuery: new(citypes.ListServicesMetadataParams),
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
ResponseContentType: "application/json",
@@ -177,9 +176,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
ID: "GetService",
Tags: []string{"cloudintegration"},
Summary: "Get service",
Description: "This endpoint gets a service for the specified cloud provider",
Description: "This endpoint gets a service definition for the specified cloud provider, without any account context.",
Request: nil,
RequestQuery: new(citypes.GetServiceParams),
RequestContentType: "",
Response: new(citypes.Service),
ResponseContentType: "application/json",

View File

@@ -251,23 +251,7 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
return
}
queryParams := new(cloudintegrationtypes.ListServicesMetadataParams)
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
// check if integration account exists and is not removed.
if !queryParams.CloudIntegrationID.IsZero() {
_, err := handler.module.GetConnectedAccount(ctx, orgID, queryParams.CloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
}
services, err := handler.module.ListServicesMetadata(ctx, orgID, provider, queryParams.CloudIntegrationID)
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, valuer.UUID{})
if err != nil {
render.Error(rw, err)
return
@@ -336,22 +320,7 @@ func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
return
}
queryParams := new(cloudintegrationtypes.GetServiceParams)
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
render.Error(rw, err)
return
}
// check if integration account exists and is not removed.
if !queryParams.CloudIntegrationID.IsZero() {
_, err := handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), queryParams.CloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
}
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, queryParams.CloudIntegrationID)
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, valuer.UUID{})
if err != nil {
render.Error(rw, err)
return

View File

@@ -100,9 +100,20 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
return newQuery.String(), nil
}
// Statement renders the SQL without executing it, for the preview path.
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
func (q *chSQLQuery) render(ctx context.Context) (string, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
if err != nil {
return "", err
}
querybuilder.LogIfStatementIsNotValid(ctx, q.logger, rendered)
return rendered, nil
}
// Statement renders the SQL without executing it, for the preview path.
func (q *chSQLQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
rendered, err := q.render(ctx)
if err != nil {
return nil, err
}
@@ -124,7 +135,7 @@ func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
elapsed += p.Elapsed
}))
query, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
query, err := q.render(ctx)
if err != nil {
return nil, err
}
@@ -135,11 +146,11 @@ func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
}
defer rows.Close()
// TODO: map the errors from ClickHouse to our error types
payload, err := consume(rows, q.kind, nil, qbtypes.Step{}, q.query.Name)
if err != nil {
return nil, err
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/queryparser"
"log/slog"
@@ -1092,6 +1093,8 @@ func (aH *APIHandler) queryDashboardVarsV2(w http.ResponseWriter, r *http.Reques
return
}
querybuilder.LogIfStatementIsNotValid(r.Context(), aH.logger, query)
dashboardVars, err := aH.reader.QueryDashboardVars(r.Context(), query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)

View File

@@ -96,9 +96,9 @@ func (r *aggExprRewriter) Rewrite(
}
if visitor.isRate {
return fmt.Sprintf("%s/%d", sel.SelectItems[0].String(), rateInterval), visitor.chArgs, nil
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, nil
}
return sel.SelectItems[0].String(), visitor.chArgs, nil
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, nil
}
// RewriteMulti rewrites a slice of expressions.
@@ -207,7 +207,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
// Handle *If functions with predicate + values
if aggFunc.FuncCombinator {
// Map the predicate (last argument)
origPred := args[len(args)-1].String()
origPred := chparser.Format(args[len(args)-1])
whereClause, err := PrepareWhereClause(
origPred,
FilterExprVisitorOpts{
@@ -242,7 +242,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
// Map each value column argument
for i := 0; i < len(args)-1; i++ {
origVal := args[i].String()
origVal := chparser.Format(args[i])
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
expr, err := v.fieldMapper.ColumnExpressionFor(v.ctx, v.orgID, v.startNs, v.endNs, &fieldKey, dataType, v.fieldKeys)
if err != nil {
@@ -259,7 +259,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
} else {
// Non-If functions: map every argument as a column/value
for i, arg := range args {
orig := arg.String()
orig := chparser.Format(arg)
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
expr, err := v.fieldMapper.ColumnExpressionFor(v.ctx, v.orgID, v.startNs, v.endNs, &fieldKey, dataType, v.fieldKeys)
if err != nil {

View File

@@ -0,0 +1,76 @@
package querybuilder
import (
"context"
"log/slog"
"strings"
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/SigNoz/signoz/pkg/errors"
)
// internalDatabases hold server metadata, credentials and grants rather than telemetry.
var internalDatabases = map[string]struct{}{
"system": {},
"information_schema": {},
}
// The parser's grammar has gaps against SQL that ClickHouse itself accepts. See TestErrIfStatementIsNotValid_ShouldPassButFails.
func ErrIfStatementIsNotValid(query string) (err error) {
defer func() {
// The parser has a history of panicking on malformed input rather than returning an error.
if recovered := recover(); recovered != nil {
err = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL (recovered): %v", recovered)
}
}()
stmts, parseErr := chparser.NewParser(query).ParseStmts()
if parseErr != nil {
// Wrapped rather than formatted in, so that callers can recover the parser's *ParseError and read the position off it.
return errors.WrapInvalidInputf(parseErr, errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", parseErr.Error())
}
if len(stmts) != 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
}
selectQuery, ok := stmts[0].(*chparser.SelectQuery)
if !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "only SELECT statements are allowed in ClickHouse SQL queries")
}
visitor := &chparser.DefaultASTVisitor{Visit: func(node chparser.Expr) error {
switch expr := node.(type) {
case *chparser.TableFunctionExpr:
// Source table functions remain usable in ClickHouse read-only mode.
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse table functions are not allowed in SQL queries: %s", chparser.Format(expr.Name))
case *chparser.TableIdentifier:
// Reading these is unaffected by ClickHouse read-only mode.
if expr.Database == nil {
return nil
}
if _, ok := internalDatabases[strings.ToLower(expr.Database.Name)]; ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
}
case *chparser.SettingExpr:
// A query-level setting takes precedence over the context setting.
if strings.EqualFold(expr.Name.Name, "readonly") {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse readonly setting cannot be overridden")
}
}
return nil
}}
return selectQuery.Accept(visitor)
}
// TODO(@therealpandey): remove this and move to ErrIfStatementIsNotValid.
func LogIfStatementIsNotValid(ctx context.Context, logger *slog.Logger, query string) {
if err := ErrIfStatementIsNotValid(query); err != nil {
logger.WarnContext(ctx, "clickhouse sql is not valid", errors.Attr(err), slog.String("query", query))
}
}

View File

@@ -0,0 +1,154 @@
package querybuilder
import (
"testing"
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
testCases := []struct {
name string
query string
}{
// Shapes a telemetry read is allowed to take.
{"Select", "SELECT region AS r, zone FROM metrics WHERE metric_name = 'cpu' GROUP BY region, zone"},
{"TrailingSemicolon", "SELECT count() FROM signoz_logs.distributed_logs_v2;"},
{"CommonTableExpression", "WITH t AS (SELECT fingerprint FROM signoz_metrics.time_series_v4) SELECT * FROM t"},
{"Join", "SELECT * FROM t1 LEFT JOIN t2 ON t1.a = t2.b"},
{"GlobalIn", "SELECT a FROM t WHERE a GLOBAL IN (SELECT b FROM t2)"},
{"Union", "SELECT * FROM t UNION ALL SELECT * FROM t2"},
{"Intersect", "SELECT * FROM t INTERSECT SELECT * FROM t2"},
{"WindowFunction", "SELECT sum(v) OVER (PARTITION BY a ORDER BY t) FROM t"},
{"UnrelatedSetting", "SELECT * FROM t SETTINGS max_threads = 4"},
{"TerminatedBlockComment", "SELECT /* keep me */ count() FROM t"},
{"BlockCommentMarkerInsideStringLiteral", "SELECT count() FROM t WHERE body = '/* not a comment'"},
// The parser used to loop forever on this; it now reads the comment to the end of
// the input, so this doubles as a canary for that regression.
{"TrailingUnterminatedBlockComment", "SELECT count() FROM t /* unterminated"},
// The rule keys on the database, not on the table name.
{"TableNamedSystemInTelemetryDatabase", "SELECT * FROM signoz_logs.system"},
{"SignedLiteralAfterClosingParen", "SELECT (toUnixTimestamp(now()) - 3600)*1000000000"},
// order by interval
{"OrderByInterval", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval ORDER BY interval"},
{"OrderByIntervalAndDirection", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS `interval` ORDER BY `interval` ASC"},
{"TrimFunction", "SELECT trimBoth('/api/endpoint/', '/');"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := ErrIfStatementIsNotValid(testCase.query)
assert.NoError(t, err)
})
}
}
func TestErrIfStatementIsNotValid_Fail(t *testing.T) {
testCases := []struct {
name string
query string
}{
// Not a single statement, or not a statement at all.
{"Empty", ""},
{"UnterminatedBlockCommentOnly", "/* x"},
{"Unparseable", "SELECT FROM WHERE"},
{"MultipleStatements", "SELECT 1; DROP TABLE signoz_logs.logs_v2"},
// Parses, but is not a SELECT.
{"Drop", "DROP TABLE signoz_logs.logs_v2"},
{"Insert", "INSERT INTO signoz_logs.logs_v2 SELECT * FROM signoz_logs.logs_v2"},
{"AlterDelete", "ALTER TABLE signoz_logs.logs_v2 DELETE WHERE 1 = 1"},
{"CreateTable", "CREATE TABLE evil (a Int) ENGINE = Memory"},
{"Grant", "GRANT ALL ON *.* TO admin"},
{"Set", "SET readonly = 0"},
// These the parser rejects outright rather than classifying.
{"ShowGrants", "SHOW GRANTS"},
{"IntoOutfile", "SELECT * FROM t INTO OUTFILE '/tmp/x.csv'"},
// Table functions, which read through something other than a telemetry table.
{"UrlTableFunction", "SELECT * FROM url('http://attacker.example/x', CSV, 'a String')"},
{"FileTableFunction", "SELECT * FROM file('/etc/passwd', CSV, 'a String')"},
{"ExecutableTableFunction", "SELECT * FROM executable('script.sh', CSV, 'a String')"},
{"TableFunctionInJoin", "SELECT * FROM t1 JOIN url('http://x', CSV, 'a String') u ON 1 = 1"},
{"TableFunctionInCommonTableExpression", "WITH c AS (SELECT * FROM url('http://x', CSV, 'a String')) SELECT * FROM c"},
{"TableFunctionInWhereSubquery", "SELECT * FROM t WHERE a IN (SELECT * FROM file('/etc/passwd', CSV, 'a String'))"},
{"TableFunctionInUnion", "SELECT * FROM t UNION ALL SELECT * FROM url('http://x', CSV, 'a String')"},
// Internal databases, which hold grants and server metadata rather than telemetry.
{"SystemUsers", "SELECT * FROM system.users"},
{"SystemUppercase", "SELECT * FROM SYSTEM.USERS"},
{"SystemQuoted", "SELECT count() FROM `system`.`tables`"},
{"SystemInSubquery", "SELECT * FROM (SELECT name FROM system.parts)"},
{"SystemInJoin", "SELECT * FROM signoz_logs.distributed_logs_v2 AS l JOIN system.users AS u ON 1 = 1"},
{"SystemInIntersect", "SELECT * FROM t INTERSECT SELECT * FROM system.users"},
{"InformationSchema", "SELECT * FROM information_schema.tables"},
// A query-level setting takes precedence over the one the caller applies.
{"ReadonlySettingOverride", "SELECT * FROM t SETTINGS readonly = 0"},
{"ReadonlySettingOverrideAmongOthers", "SELECT * FROM t SETTINGS max_threads = 4, readonly = 0"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := ErrIfStatementIsNotValid(testCase.query)
assert.Error(t, err)
})
}
}
// Queries the parser cannot read. ClickHouse runs all of them.
func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
testCases := []struct {
name string
query string
// The construct the parser stops after, which is the one it cannot read.
expectedStopsAfter string
// The same construct written so the parser accepts it.
fix string
}{
{
name: "IntervalAliasInOrderBy",
query: "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval, resource_string_service$$name AS `service.name`, attributes_string['http.route'] AS `http.route`, quantile(0.95)(duration_nano) / 1000000000 AS value FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_string_service$$name = 'svc-a' AND resources_string['deployment.environment'] = 'dev' AND attributes_string['http.route'] = '/v1' AND http_method = 'POST' AND timestamp BETWEEN toDateTime(1784601720) AND toDateTime(1784602620) AND ts_bucket_start BETWEEN 1784601720 - 1800 AND 1784602620 GROUP BY `service.name`, `http.route`, interval ORDER BY interval ASC",
expectedStopsAfter: "ORDER BY interval ASC",
fix: "SELECT count() AS interval FROM t ORDER BY `interval` ASC",
},
{
name: "IntervalAliasInOrderByDesc",
query: "SELECT count() AS value, toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval, serviceName, resourceTagsMap['deployment.environment'] AS environment, exceptionStacktrace FROM signoz_traces.distributed_signoz_error_index_v2 WHERE exceptionType != 'OSError' AND resourceTagsMap['deployment.environment'] = 'staging' AND timestamp BETWEEN toDateTime(1785186300) AND toDateTime(1785186600) GROUP BY serviceName, interval, environment, exceptionStacktrace ORDER BY interval DESC",
expectedStopsAfter: "ORDER BY interval DESC",
fix: "SELECT count() AS interval FROM t ORDER BY `interval` DESC",
},
{
name: "StandardTrimSyntax",
query: "SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 5 MINUTE) AS interval, resources_string['host.name'] as host_name, toFloat64(countIf( lower(trim(BOTH ' ' FROM replaceOne( JSONExtractString(body, 'Action'), 'health_status: ', '' ))) IN ('unhealthy','starting','failing') )) as value FROM signoz_logs.distributed_logs_v2 WHERE timestamp BETWEEN 1784602320000000000 AND 1784602620000000000 AND ts_bucket_start BETWEEN 1784602320 - 300 AND 1784602620 AND JSONExtractString(body, 'Type') = 'container' AND JSONExtractString(body, 'Actor', 'Attributes', 'name') IS NOT NULL AND resources_string['host.name'] IS NOT NULL AND resources_string['host.name'] = 'aihub-nightly' GROUP BY interval, host_name ORDER BY interval, host_name",
expectedStopsAfter: "trim(BOTH '",
fix: "SELECT trimBoth(replaceOne( JSONExtractString(body, 'Action'), 'health_status: ', '' ), ' ')",
},
{
name: "SignedLiteralAfterClosingParen",
query: "SELECT now() AS ts, toFloat64(count()) AS value FROM ( SELECT attributes_string['TableName'] AS T, attributes_string['MissingId'] AS M, max(fromUnixTimestamp64Nano(timestamp)) AS last_seen, dateDiff('minute', min(fromUnixTimestamp64Nano(timestamp)), max(fromUnixTimestamp64Nano(timestamp))) AS age_min FROM signoz_logs.distributed_logs_v2 WHERE body='missing_map_record' AND timestamp >= (toUnixTimestamp(now())-3600)*1000000000 GROUP BY T, M ) WHERE age_min >= 20 AND last_seen >= now() - toIntervalMinute(8)",
expectedStopsAfter: "(toUnixTimestamp(now())",
fix: "SELECT (toUnixTimestamp(now()) - 3600) * 1000000000",
},
{
name: "SignedLiteralAfterClosingParenMinimal",
query: "SELECT (1)-1",
expectedStopsAfter: "(1)",
fix: "SELECT (1) - 1",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := ErrIfStatementIsNotValid(testCase.query)
var parseErr *chparser.ParseError
require.ErrorAs(t, err, &parseErr, "expected a parser failure rather than a rule violation")
// The parser reports the offset it stopped at, which sits just past the construct
// it choked on, so the text leading up to it is what needs looking at.
consumed := testCase.query[:parseErr.Pos]
assert.Equal(t, testCase.expectedStopsAfter, consumed[max(0, len(consumed)-len(testCase.expectedStopsAfter)):])
assert.NoError(t, ErrIfStatementIsNotValid(testCase.fix))
})
}
}

View File

@@ -1 +0,0 @@
package querybuilder

View File

@@ -316,17 +316,17 @@ func (e *ClickHouseFilterExtractor) extractColumnStrByExpr(expr clickhouse.Expr)
// FunctionExpr is a function call like "toDate(timestamp)"
case *clickhouse.FunctionExpr:
// For function expressions, return the complete function call string
return ex.String()
return clickhouse.Format(ex)
// ColumnExpr is a column expression like "m.region", "toDate(timestamp)"
case *clickhouse.ColumnExpr:
// ColumnExpr wraps another expression - extract the underlying expression
if ex.Expr != nil {
return e.extractColumnStrByExpr(ex.Expr)
}
return ex.String()
return clickhouse.Format(ex)
default:
// For other expression types, return the string representation
return expr.String()
return clickhouse.Format(expr)
}
}
@@ -524,7 +524,7 @@ func (e *ClickHouseFilterExtractor) extractFullExpression(expr clickhouse.Expr)
if expr == nil {
return ""
}
return expr.String()
return clickhouse.Format(expr)
}
// isSimpleColumnReference checks if an expression is just a simple column reference
@@ -657,7 +657,7 @@ func (e *ClickHouseFilterExtractor) extractCTEName(cte *clickhouse.CTEStmt) stri
case *clickhouse.Ident:
return name.Name
default:
return cte.Expr.String()
return clickhouse.Format(cte.Expr)
}
}

View File

@@ -103,7 +103,7 @@ func (migration *addTelemetryTuples) Up(ctx context.Context, db *bun.DB) error {
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err

View File

@@ -27,7 +27,7 @@ func (v *TelemetryFieldVisitor) VisitColumnDef(expr *parser.ColumnDef) error {
}
// Parse column name to extract context and data type
columnName := expr.Name.String()
columnName := parser.Format(expr.Name)
// Remove backticks if present
columnName = strings.TrimPrefix(columnName, "`")
@@ -75,7 +75,7 @@ func (v *TelemetryFieldVisitor) VisitColumnDef(expr *parser.ColumnDef) error {
// Extract field name from the DEFAULT expression
// The DEFAULT expression should be something like: resources_string['k8s.cluster.name']
// We need to extract the key inside the square brackets
defaultExprStr := expr.DefaultExpr.String()
defaultExprStr := parser.Format(expr.DefaultExpr)
// Look for the pattern: map['key']
startIdx := strings.Index(defaultExprStr, "['")

View File

@@ -44,10 +44,6 @@ type GettableServicesMetadata struct {
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
}
type ListServicesMetadataParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
@@ -63,10 +59,6 @@ type ServiceAssets struct {
Dashboards []*ServiceDashboard `json:"dashboards" required:"true" nullable:"false"`
}
type GetServiceParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
type UpdatableService struct {
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
}

View File

@@ -180,6 +180,11 @@ func placedWidgetIDs(data StorableDashboardData) map[string]bool {
for _, e := range layout {
if m, ok := e.(map[string]any); ok {
if i, ok := m["i"].(string); ok && i != "" {
// A zero-width placement doesn't render in the v1 UI; treat it as
// unplaced so the widget is dropped rather than migrated invisibly.
if w, ok := m["w"].(float64); ok && w <= 0 {
continue
}
ids[i] = true
}
}
@@ -320,6 +325,9 @@ func compactGridItemsVertically(items []dashboard.GridItem) {
if l.X < 0 {
l.X = 0
}
if l.X+l.Width > gridColumnCount { // still overflows (wider than the grid) → clamp width so x+width = cols
l.Width = gridColumnCount - l.X
}
if l.Y < 0 {
l.Y = 0
}

View File

@@ -3,6 +3,7 @@ package dashboardtypes
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
@@ -108,26 +109,12 @@ func normalizeFunctionArgs(query map[string]any) {
}
}
// malformedOrderByValueKeys are v4 order-by columnNames meaning "order by the aggregation value"
// that the v5 aggregation validator rejects (validateOrderByForAggregation). All resolve
// to the same aggregation key. Add more as they surface. The frontend passes these
// through (the query-service resolves them), but the v2 dashboard validator only accepts
// a real aggregation key.
var malformedOrderByValueKeys = map[string]bool{
"#SIGNOZ_VALUE": true,
"A": true,
"A.count()": true,
"__result": true,
"value": true,
"A.p99(duration_nano)": true,
"aws_Kafka_MessagesInPerSec_max": true,
"byte_in_count": true,
"(http_server_request_duration_ms.bucket)": true,
}
// normalizeOrderByKeys rewrites any orderBy columnName in orderByValueKeys to the
// v5-valid aggregation key. Left untouched if the key can't resolve (no aggregation to
// name).
// normalizeOrderByKeys rewrites any orderBy columnName the v5 aggregation validator
// would reject (validateOrderByForAggregation) to the canonical aggregation value key.
// v1 tolerated free-form "order by the value" aliases (#SIGNOZ_VALUE, the query name,
// the raw metric/expression) that the query-service resolved at query time; v2 accepts
// only a real order key. Anything already valid (a group-by key, an aggregation
// expression/alias/index) is left alone. No-op if no aggregation key can be named.
func normalizeOrderByKeys(query map[string]any) {
orders, ok := query["orderBy"].([]any)
if !ok {
@@ -137,17 +124,88 @@ func normalizeOrderByKeys(query map[string]any) {
if !ok {
return
}
valid := validAggregationOrderKeys(query)
for _, o := range orders {
order, ok := o.(map[string]any)
if !ok {
continue
}
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[cn] {
if cn, _ := order["columnName"].(string); cn != "" && !valid[cn] {
order["columnName"] = key
}
}
}
// validAggregationOrderKeys is a line-for-line mirror of validateOrderByForAggregation
// (querybuildertypesv5/validation.go) over the untyped query map instead of a typed
// QueryBuilderQuery. Keep the two in lockstep — every insertion here must match one
// there — so a side-by-side read makes any drift obvious. The only adaptations: fields
// are read out of maps, the type switch on the aggregation becomes a switch on the
// query signal (metrics vs logs/traces), and a not-yet-upgraded group-by still carries
// the v4 "key" instead of the v5 "name".
func validAggregationOrderKeys(query map[string]any) map[string]bool {
validOrderKeys := make(map[string]bool)
for _, gb := range asObjects(query["groupBy"]) {
name, _ := gb["name"].(string)
if name == "" {
name, _ = gb["key"].(string)
}
validOrderKeys[name] = true
}
signal := signalFromDataSource(query["dataSource"])
for i, agg := range asObjects(query["aggregations"]) {
validOrderKeys[fmt.Sprintf("%d", i)] = true
switch signal {
// TraceAggregation / LogAggregation (identical bodies in the validator).
case telemetrytypes.SignalTraces, telemetrytypes.SignalLogs:
if alias, _ := agg["alias"].(string); alias != "" {
validOrderKeys[alias] = true
}
expression, _ := agg["expression"].(string)
validOrderKeys[expression] = true
// MetricAggregation.
case telemetrytypes.SignalMetrics:
// Also allow the generic __result pattern
validOrderKeys["__result"] = true
metricName, _ := agg["metricName"].(string)
spaceRaw, _ := agg["spaceAggregation"].(string)
timeRaw, _ := agg["timeAggregation"].(string)
spaceAggregation := metrictypes.SpaceAggregation{String: valuer.NewString(spaceRaw)}
timeAggregation := metrictypes.TimeAggregation{String: valuer.NewString(timeRaw)}
validOrderKeys[fmt.Sprintf("%s(%s)", spaceAggregation.StringValue(), metricName)] = true
if timeAggregation != metrictypes.TimeAggregationUnspecified {
validOrderKeys[fmt.Sprintf("%s(%s)", timeAggregation.StringValue(), metricName)] = true
}
if timeAggregation != metrictypes.TimeAggregationUnspecified && spaceAggregation != metrictypes.SpaceAggregationUnspecified {
validOrderKeys[fmt.Sprintf("%s(%s(%s))", spaceAggregation.StringValue(), timeAggregation.StringValue(), metricName)] = true
}
}
}
return validOrderKeys
}
// asObjects returns the map elements of a []any, skipping non-object entries.
func asObjects(raw any) []map[string]any {
items, ok := raw.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(items))
for _, it := range items {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// aggregationOrderKey names the first aggregation the way validateOrderByForAggregation
// expects: "space(metricName)" for metrics, the expression for logs/traces.
func aggregationOrderKey(query map[string]any) (string, bool) {

View File

@@ -1738,6 +1738,47 @@ func TestConvertV1WidgetQueryRewritesValueOrderKeyAfterDefaultAggregation(t *tes
assert.Equal(t, "count()", spec.Order[0].Key.Name, "#SIGNOZ_VALUE resolves to the injected default aggregation")
}
// A metric query ordering by a value alias the v5 validator rejects (here the raw
// metric name, never enumerated anywhere) is rewritten to the canonical aggregation
// key, while a genuine group-by order key is left alone. Guards the allowlist
// approach: validity is derived from the query, not a hardcoded set of bad keys.
func TestConvertV1WidgetQueryRewritesUnknownMetricValueOrderKey(t *testing.T) {
widget := map[string]any{
"id": "m-1",
"panelTypes": "graph",
"query": map[string]any{
"queryType": "builder",
"builder": map[string]any{
"queryData": []any{
map[string]any{
"queryName": "A",
"expression": "A",
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "http_requests_total", "spaceAggregation": "sum"}},
"groupBy": []any{map[string]any{"key": "service.name", "dataType": "string", "type": "resource"}},
"orderBy": []any{
map[string]any{"columnName": "http_requests_total", "order": "desc"},
map[string]any{"columnName": "service.name", "order": "asc"},
},
},
},
},
},
}
queries := (&v1Decoder{}).convertV1WidgetQuery(widget, PanelKindTimeSeries)
require.Len(t, queries, 1)
wrapper, ok := queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec)
require.True(t, ok)
spec, ok := wrapper.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
require.True(t, ok, "metrics query should dispatch to MetricAggregation, got %T", wrapper.Spec)
require.Len(t, spec.Order, 2)
assert.Equal(t, "sum(http_requests_total)", spec.Order[0].Key.Name, "unknown value alias rewritten to the aggregation key")
assert.Equal(t, "service.name", spec.Order[1].Key.Name, "valid group-by order key left alone")
}
func TestConvertV1WidgetQueryInjectsCountForNoopOnAggregationPanel(t *testing.T) {
// A logs query with the list-style "noop" operator placed on an aggregation
// panel (graph). createAggregationsShapeSafe drops noop, leaving no aggregation;
@@ -2084,6 +2125,26 @@ func TestConvertV1LayoutsClampsXBounds(t *testing.T) {
assert.Equal(t, 6, grid.Items[1].X) // x+w=16>12 shifted left to 12-6
}
func TestConvertV1LayoutsClampsOverwideWidth(t *testing.T) {
// A widget wider than the 12-col grid (e.g. carried over from a 24-col v1 grid)
// can't be shifted to fit, so its width is clamped so x+width = grid width —
// otherwise it overflows and v2 validation rejects it.
data := StorableDashboardData{
"widgets": []any{map[string]any{"id": "wide", "panelTypes": "graph", "query": singleLogsBuilderQuery()}},
"layout": []any{map[string]any{"i": "wide", "x": float64(0), "y": float64(0), "w": float64(24), "h": float64(6)}},
}
d := &v1Decoder{}
layouts := d.convertV1Layouts(data, d.convertV1Panels(data["widgets"]))
require.NoError(t, d.errIfHasMalformedFields())
require.Len(t, layouts, 1)
grid, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
require.True(t, ok)
require.Len(t, grid.Items, 1)
assert.Equal(t, 0, grid.Items[0].X)
assert.Equal(t, 12, grid.Items[0].Width) // w=24 clamped to 12 so x+width = 12
}
// TestConvertV1LayoutsToleratesNonObjectPanelMap covers templates that store
// panelMap as {rowID: []widgetID} instead of the canonical {rowID: {widgets,
// collapsed}}. The frontend reads such an entry as "not collapsed" (it accesses
@@ -2152,6 +2213,33 @@ func TestConvertV1LayoutsDropsEntryForUnrenderableWidget(t *testing.T) {
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
}
func TestRetainPlacedWidgetsDropsZeroWidth(t *testing.T) {
// z-1 is placed with zero width, which the v1 UI doesn't render. It must be
// dropped entirely: no panel, and no dangling layout entry.
data := StorableDashboardData{
"widgets": []any{
map[string]any{"id": "p-1", "panelTypes": "graph", "query": singleLogsBuilderQuery()},
map[string]any{"id": "z-1", "panelTypes": "graph", "query": singleLogsBuilderQuery()},
},
"layout": []any{
map[string]any{"i": "p-1", "x": float64(0), "y": float64(0), "w": float64(6), "h": float64(6)},
map[string]any{"i": "z-1", "x": float64(6), "y": float64(0), "w": float64(0), "h": float64(6)},
},
}
d := &v1Decoder{}
panels := d.convertV1Panels(retainPlacedWidgets(data))
require.Contains(t, panels, "p-1")
require.NotContains(t, panels, "z-1", "a zero-width widget is not retained → no panel")
layouts := d.convertV1Layouts(data, panels)
require.Len(t, layouts, 1)
spec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
require.True(t, ok)
require.Len(t, spec.Items, 1, "the zero-width widget's layout entry is dropped too")
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
}
func TestConvertV1LayoutsDropsCollapsedChildWithNoPanel(t *testing.T) {
// A collapsed section lists a child ("ghost") that has no widget at all — a
// deleted widget still referenced in panelMap. It produces no panel and no

View File

@@ -69,7 +69,7 @@ func (qp *QueryProcessor) ProcessQuery(query string, transformer FilterTransform
// Reconstruct the query
var resultBuilder strings.Builder
for _, stmt := range stmts {
resultBuilder.WriteString(stmt.String())
resultBuilder.WriteString(parser.Format(stmt))
resultBuilder.WriteString(";")
}

View File

@@ -31,7 +31,7 @@ def test_list_services_without_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""List available services without specifying a cloud_integration_id."""
"""List the cloud provider's supported services"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
@@ -54,38 +54,6 @@ def test_list_services_without_account(
assert "enabled" in service, "Service should have 'enabled' field"
def test_list_services_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""List services filtered to a specific account — all disabled by default."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert "services" in data, "Response should contain 'services' field"
assert len(data["services"]) > 0, "services list should be non-empty"
for svc in data["services"]:
assert "enabled" in svc, "Each service should have 'enabled' field"
assert svc["enabled"] is False, f"Service {svc['id']} should be disabled before any config is set"
EC2_SERVICE_ID = "ec2"
@@ -154,34 +122,6 @@ def test_get_service_details_without_account(
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
def test_get_service_details_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Get service details with account context — cloudIntegrationService is null before first UpdateService."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == SERVICE_ID
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any service config is set"
def test_get_account_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
@@ -252,7 +192,7 @@ def test_update_service_config(
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -302,7 +242,7 @@ def test_update_service_config_disable(
assert r.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {r.status_code}: {r.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -355,7 +295,7 @@ def test_list_services_account_removed(
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""List services with a cloud_integration_id for a deleted account returns 404."""
"""List services for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
@@ -372,7 +312,7 @@ def test_list_services_account_removed(
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -386,7 +326,7 @@ def test_get_service_details_account_removed(
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Get service details with a cloud_integration_id for a deleted account returns 404."""
"""Get service details for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
@@ -403,7 +343,7 @@ def test_get_service_details_account_removed(
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -468,7 +408,7 @@ def test_enable_metrics_provisions_dashboards(
# Assertion 1: GetService returns provisioned dashboard UUIDs
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -533,7 +473,7 @@ def test_disable_metrics_deprovisions_dashboards(
# Capture the provisioned dashboard IDs before disabling
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -552,7 +492,7 @@ def test_disable_metrics_deprovisions_dashboards(
# Assertion 1: GetService no longer returns UUID dashboard IDs
get_svc_after = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -20,12 +20,15 @@ from fixtures.querier import (
# Numeric aggregation-function coverage for logs — the logs counterpart of
# queriertraces/02_aggregation.py::test_traces_aggregate_functions. Logs querier
# tests elsewhere only ever use count()/count_distinct(); here a grouped scalar
# query computes sum / avg / min / max / p50 / p90 / p95 / p99 / countIf /
# count_distinct over a numeric log attribute and asserts every value.
# query computes sum / avg / min / max / p50 / p90 / p95 / p99 / countIf / rate /
# rate_sum / count_distinct over a numeric log attribute and asserts every value.
#
# Log number attributes are stored as Float64, so aggregates come back as floats;
# percentiles are matched with pytest.approx (ClickHouse quantile() and
# numpy.percentile both linear-interpolate, but avoid ULP-level exact equality).
#
# The rate family divides by a window the caller does not otherwise see, so the
# lookback is passed explicitly instead of taken from the request helper default.
def test_logs_aggregate_functions(
@@ -41,11 +44,14 @@ def test_logs_aggregate_functions(
Tests:
A grouped scalar query computes count / sum / avg / min / max / p50 / p90 /
p95 / p99 over latency_ms, countIf over a numeric threshold, and
count_distinct over a string attribute — all matching values derived from the
inserted logs, ordered by count() desc.
p95 / p99 over latency_ms, countIf over a numeric threshold, rate and
rate_sum over the query window, and count_distinct over a string attribute —
all matching values derived from the inserted logs, ordered by count() desc.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# A scalar rate divides by the whole query window, so both sides agree on it.
lookback_minutes = 5
rate_interval_seconds = lookback_minutes * 60
# (service, latency_ms, endpoint)
specs = [
("svc-a", 10, "/x"),
@@ -80,12 +86,14 @@ def test_logs_aggregate_functions(
build_aggregation("p95(latency_ms)", "p95_l"),
build_aggregation("p99(latency_ms)", "p99_l"),
build_aggregation("countIf(latency_ms >= 25)", "slow"),
build_aggregation("rate()", "rate_all"),
build_aggregation("rate_sum(latency_ms)", "rate_sum_l"),
build_aggregation("count_distinct(endpoint)", "endpoints"),
],
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count()", "desc")],
)
response = make_scalar_query_request(signoz, token, now, [query])
response = make_scalar_query_request(signoz, token, now, [query], lookback_minutes=lookback_minutes)
assert response.status_code == HTTPStatus.OK, response.text
data = get_scalar_table_data(response.json())
@@ -109,6 +117,12 @@ def test_logs_aggregate_functions(
float(np.percentile(latencies, 95)), # p95(latency_ms)
float(np.percentile(latencies, 99)), # p99(latency_ms)
sum(1 for latency in latencies if latency >= 25), # countIf(latency_ms >= 25)
# Response floats are rounded to 3 decimals at or above 1 and to 3
# significant figures below it (roundToNonZeroDecimals). Every other
# value here is exact; the rates are the only ones that repeat, and
# they stay below 1 for this fixture, so mirror the latter form.
float(f"{len(latencies) / rate_interval_seconds:.3g}"), # rate()
float(f"{sum(latencies) / rate_interval_seconds:.3g}"), # rate_sum(latency_ms)
len(set(endpoints)), # count_distinct(endpoint)
]
assert by_service[service] == pytest.approx(expected), f"{service}: {by_service[service]} != {expected}"

View File

@@ -150,12 +150,17 @@ def test_traces_aggregate_functions(
Tests:
A grouped scalar query computes count / sum / avg / min / max / p50 / p90 over
duration_nano, countIf over the intrinsic status_code and the calculated
response_status_code, and avg over a numeric attribute — all matching values
derived from the inserted spans. Under the corrupt variant the same-named
colliding attributes must not change any of these.
response_status_code, rate and rate_sum over the query window, and avg over a
numeric attribute — all matching values derived from the inserted spans. Under
the corrupt variant the same-named colliding attributes must not change any of
these.
"""
extra_attrs, extra_resources = trace_noise(noise)
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# A scalar rate divides by the whole query window, so both sides agree on it.
# Traces derives this separately from logs (telemetrytraces/statement_builder.go).
lookback_minutes = 5
rate_interval_seconds = lookback_minutes * 60
def mk(service: str, dur_s: float, status: TracesStatusCode, rsc: str, latency: float) -> Traces:
return Traces(
@@ -189,6 +194,8 @@ def test_traces_aggregate_functions(
build_aggregation("p50(duration_nano)", "p50_d"),
build_aggregation("p90(duration_nano)", "p90_d"),
build_aggregation("countIf(status_code = 2)", "errs"),
build_aggregation("rate()", "rate_all"),
build_aggregation("rate_sum(duration_nano)", "rate_sum_d"),
build_aggregation("avg(latency_ms)", "avg_lat"),
]
query = build_traces_scalar_query(
@@ -196,7 +203,7 @@ def test_traces_aggregate_functions(
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count()", "desc")],
)
response = make_scalar_query_request(signoz, token, now, [query])
response = make_scalar_query_request(signoz, token, now, [query], lookback_minutes=lookback_minutes)
assert response.status_code == HTTPStatus.OK, response.text
data = get_scalar_table_data(response.json())
@@ -214,6 +221,11 @@ def test_traces_aggregate_functions(
float(np.percentile(durations, 50)), # p50(duration_nano)
float(np.percentile(durations, 90)), # p90(duration_nano)
sum(1 for s in group if int(s.status_code) == 2), # countIf(status_code = 2)
# Response floats are rounded to 3 significant figures below 1 and to 3
# decimals at or above it (roundToNonZeroDecimals). The two rates land on
# either side of that boundary, so each mirrors its own form.
float(f"{len(group) / rate_interval_seconds:.3g}"), # rate()
round(sum(durations) / rate_interval_seconds, 3), # rate_sum(duration_nano)
sum(latencies) / len(latencies), # avg(latency_ms)
)