Compare commits

...

6 Commits

Author SHA1 Message Date
nityanandagohain
4d4502fa85 chore: enable ai 011y processors by default 2026-09-18 20:46:39 +05:30
Gaurav Tewari
35973efd65 feat(quick-filters): AI o11y quick filters (#12788)
#### Description

- The AI o11y explorer was reusing the traces explorer's quick filters.
It now uses its own `ai_observability` source and signal, so it loads
the gen_ai filter set.
- Values and settings keys go to
`/api/v1/ai_observability/fields/{values,keys}`, which scope suggestions
to gen_ai spans.


#### Issues closed by this PR
Close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108147&issue=SigNoz%7Cengineering-pod%7C5844

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/f7124648-9919-46e5-9e6d-90bd72f91a50


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 09:49:13 +00:00
Naman Verma
c65845e525 feat: add more slack configuration opts in notification channels (#12907)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Some common configuration options for a slack notification have been
added.

Also, webhook notification channels can now run without a username,
without a password, or without any auth.
2026-09-18 09:20:19 +00:00
Gaurav Tewari
f6f41df237 feat(ai-observability): Trace view changes (#12796)
#### Description

- Trace is now the explorer's default view (`DEFAULT_PANEL_TYPE`) and
the first toolbar tab.
- `LeftToolbarActions` becomes config-driven: buttons render in the
order the caller declares its views, from a `TOOLBAR_VIEW_CONFIG`
lookup, instead of five hardcoded per-view blocks. That also replaces
the `items: any` prop with a typed `Record<string, ToolbarViewItem>`.
- Fixes a column-init race in the trace view. Rows can land before the
field keys, and mounting then persisted a partial column set as if the
user had chosen it. `useTraceViewColumns` now hands out the column
storage key only once the keys fetch succeeds — every write path in
`useColumnState` no-ops without one, so the key is the write barrier.
Until then the table stays unmounted, the Options control is hidden, and
a render falls back to the default-visible columns with no key attached.
- Removes the saved-views / export-to-dashboard bar from the explorer
and the download menu from the list view — its export path only handles
`PANEL_TYPES.LIST`, so it could not reflect the selected columns.
`getQueryByPanelType` and `getExportQueryData` go with them. Table and
Time Series keep their exports under AI-specific filenames, via a new
opt-in `exportFileName` on the shared `TimeSeriesView` (defaulted, so
existing callers are unchanged).
- List view drops `useOptionsMenu`: columns are the static
`defaultSelectedColumns` (now typed `TelemetryFieldKey[]`) until the
preferences framework lands, and the table keeps its own column order
under `AI_OBSERVABILITY_LIST_COLUMNS` instead of sharing the traces
explorer's.
- `start_time`/`end_time`/`last_activity_time` join
`TIMESTAMP_FIELD_NAMES` and
`trace_duration_nano`/`max_llm_duration_nano` join
`DURATION_FIELD_NAMES`, so the shared `FieldCell` formats the
trace-level columns instead of a separate component.
- The explorer now imports its own forked `aiActions`, `Controls`,
`TracesTable` and list utils rather than reaching into `TracesExplorer`.
- Trims the forked `ListView/utils.tsx` to the two helpers the AI
explorer uses. `getListColumns`, `BlockLink` and `transformDataWithDate`
are antd-era machinery whose only consumer is `TracesTableComponent`,
which still imports them from the untouched original.
- Tests for the trace view, its column hook, and the table's column-init
race.
- Unrelated one-liner: `FieldKeysConfig`/`FieldValuesConfig` now point
at the generic endpoint's param types instead of being a union with the
AI ones. `Omit` over a union keeps only the keys both members share, so
`FieldKeysConfigProp` was silently losing `source`, `metricName` and
`metricNamespace`, and metrics/meter could not have used the picker. The
AI params are a subset of the generic ones and the endpoint ignores
extras.

#### Issues closed by this PR

close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108466&issue=SigNoz%7Cengineering-pod%7C5845

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/b593e160-f81e-49d4-a092-20d86bc46515

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 08:36:26 +00:00
Abhi kumar
6360da7d7c refactor(query-builder): make panel field config actually drive the builder (#12793)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `queryBuilderFields` on a panel definition had no effect.
`QueryBuilderV2` discarded the prop for list panels (the only kind that
declared anything), nothing downstream read `isHidden`/`isDisabled`, and
the `filters` / `whereClauseConfig` entry had no consumer anywhere in
the repo. The behaviour it appeared to configure came entirely from
`isListViewPanel`.
- Replaces it with a config in the builder's own vocabulary — a
per-field `hidden` / `disabled` / `pinned` rule over
`QueryBuilderField`, covering per-query controls plus `Formula` and
`AdditionalQueries`. A config can only narrow what the builder already
supports for the current data source and panel type, so definitions
never restate the builder's rules. `reason` is required on `disabled` so
an inert control always explains itself.
- `isListViewPanel` becomes `isRawQuery`: it was named for a dashboard
panel type but lives in a component three explorers use. It supplies the
defaults for `fieldsConfig` and the new `allowedDataSources`, which
callers override per field. On the dashboards side it is read from the
`requestType` a kind already declares, replacing a hardcoded
`signoz/ListPanel` check.
- Deletes the dead plumbing this uncovered:
`FilterConfigs`/`WhereClauseConfig`, the
`queryComponents`/`renderOrderBy` prop, Formula's
`isAdditionalFilterEnable` block and the four modules only it reached.

Net -900 lines. No behaviour change intended.

#### Additional Information

- Reviewing by commit is easier than by file; the four are split by
concern.
- **Formula-level HAVING is gone for real.** It only rendered behind
`isAdditionalFilterEnable`, whose sole call site passed `false`, and
QBv2 never reimplemented it — so this removes the only implementation
rather than one of two. Shout if that was on someone's roadmap.
- **`renderOrderBy` was already dead**, which means Logs and Traces
Explorer silently lost their `ExplorerOrderBy` control when QBv2 landed.
I removed the prop but left the component on disk, since that looks like
an unintended regression rather than intended cleanup.
- **Known gap:** the metrics aggregation section is outside the config.
`MetricsAggregateSection` renders its own group-by, space aggregation
and step interval, so `{ groupBy: { state: 'hidden' } }` looks like it
works on a metrics query and does not. Worth closing separately.
- `disabled` is implemented, not just declared — greyed control,
`reason` in the tooltip, refuses activation — but nothing declares it
yet; ListPanel still hides. Switching any field over is a one-key edit.
2026-09-18 07:30:50 +00:00
Nikhil Mantri
d5a9f5f022 Feat: How to use doc for sqlcompiler (#12869)
#### Description

1. Adds `docs/contributing/go/sqlcompiler.md`, a contributing doc for
`package sqlcompiler` (the shared list filter DSL to SQL compiler
extracted from dashboards in #12806).
2. Covers, in order: the DSL itself (grammar, boolean structure,
comparisons, free text), what the framework already handles (parsing,
tree walking, operator extraction, predicate builders, error
accumulation, arg binding), and what a module must supply (a
`FieldResolver`, with the dashboards resolver as the reference
implementation).
3. Documents the wiring pattern: a thin module-level `Compile` wrapper
mapping compiler errors to the module's error code, keys and allowed
operators declared in `pkg/types/<domain>` and advertised as
`reservedKeywords`.
4. Adds the doc to the index in `docs/contributing/go/readme.md`.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#349
2026-09-18 07:23:09 +00:00
110 changed files with 2450 additions and 2075 deletions

View File

@@ -384,13 +384,49 @@ components:
required:
- routingKey
type: object
AlertmanagertypesChannelSlackAction:
properties:
confirm:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
name:
type: string
style:
type: string
text:
type: string
type:
type: string
url:
type: string
value:
type: string
required:
- type
- text
type: object
AlertmanagertypesChannelSlackConfig:
properties:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
type: array
apiUrl:
format: password
type: string
channel:
type: string
color:
type: string
fallback:
type: string
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
type: array
footer:
type: string
pretext:
type: string
sendResolved:
nullable: true
type: boolean
@@ -398,9 +434,37 @@ components:
type: string
title:
type: string
titleLink:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelSlackConfirmation:
properties:
dismissText:
type: string
okText:
type: string
text:
type: string
title:
type: string
required:
- text
type: object
AlertmanagertypesChannelSlackField:
properties:
short:
nullable: true
type: boolean
title:
type: string
value:
type: string
required:
- title
- value
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:

View File

@@ -0,0 +1,181 @@
# DSL Filtering to SQL
To support search on any entity's list page (dashboards, alert rules, ...), use [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go). It compiles a filter DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. This doc explains what the compiler already does and what an adopting module supplies: a `FieldResolver` that says which keys exist and what each maps to.
The dashboards list is the adopter today; the alert rules list revamp is adopting it next.
## What is the DSL?
A few queries, from simple to full:
```
payment
status = active AND name CONTAINS cpu
(labels.team IN ('infra', 'platform') OR labels.env EXISTS) AND created_at > '2025-01-01T00:00:00Z'
"name = something"
```
- `payment` is free text: a bare token with no key, matched as a substring wherever the module decides (name, description, ...).
- `status = active AND name CONTAINS cpu` is two comparisons of the shape `key OP value`. The `AND` is optional; adjacent terms are an implicit `AND`.
- The third query shows grouping and precedence: parentheses > `NOT` > `AND` > `OR`. Values are bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
- `"name = something"` is quoted, so it is free text for that exact phrase instead of a `name = something` comparison. Quoting is the escape hatch for a phrase that looks like DSL.
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4) (see its `comparison` rule for the full operator list), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere.
## What does the framework already cover?
```go
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
type Compiled struct {
SQL string
Args []any
}
```
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. `Compiled.SQL` is the WHERE clause with `?` placeholders and `Compiled.Args` holds the bind arguments in placeholder order; the store passes both to bun. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. The package handles:
- Parsing, with syntax errors collected at line/column positions instead of failing on the first one.
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
- Typed value extraction with accumulated errors: the user sees every problem in the query at once.
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
The resolver is called once per term and builds each predicate with helpers the compiler provides (next section).
## When do I write a FieldResolver?
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
```go
type FieldResolver interface {
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
ResolveFreeText(v *Visitor, value string) string
}
```
- `ResolveComparison` is called once per `key OP value` term. It decides whether the key exists and which column expression it maps to, and returns the SQL predicate for the term.
- `ResolveFreeText` is called for a bare or quoted keyless token. It returns a predicate matching the token across whatever the module considers searchable (name, description, tags, ...).
- Both report a bad key, operator or value with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all accumulated errors.
The `*Visitor` passed in provides everything needed to build predicates. Use these instead of hand-building SQL or managing arguments yourself:
| On the `Visitor` | Use |
| --- | --- |
| `Sb` | the compile's root `SelectBuilder`; predicates and their arguments attach to it |
| `Formatter` | dialect-portable column expressions (`JSONExtractString`, `LowerExpression`) valid on both SQLite and Postgres |
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
| `BuildBoolComparison` | `= true/false` |
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
| `ExtractSingleStringValue`, `ExtractStringValueList` | typed value extraction when building a custom predicate |
| `AddError` | report a problem; errors accumulate |
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
```go
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
switch key {
case "created_by":
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)
case "created_at":
return v.BuildTimestampComparison(ctx, operation, "sample_entity.created_at")
case "locked":
return v.BuildBoolComparison(ctx, operation, "sample_entity.locked")
}
v.AddError("unknown key %q", key)
return ""
}
func (sampleEntityFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
return v.BuildFreeTextContains(v.Sb, "sample_entity.name", value)
}
```
### Special cases
Each entity decides its own key policy. The sections below grow the `sample_entity` resolver; the full real-world adopter to read alongside is dashboards' resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go).
#### Reserved and non-reserved keys
A resolver splits the key space in two:
- Reserved keys are properties the entity defines for all its instances: every `sample_entity` has a `name`, `created_by`, `created_at` and `locked`, so those keys are claimed up front and always mean that property. The list API can advertise the set (dashboards and rules return `reservedKeywords`) so frontend suggestions never go stale.
- Every other key is non-reserved: things users attach to individual instances as they want. For `sample_entity` those are labels, so `team = infra` matches only the instances a user labeled `team: infra` (built out under [Relation tables](#relation-tables)). Dashboards exposes tags the same way, and an entity is free to back this with any other per-instance construct. An entity with nothing user-attached rejects unknown keys with `v.AddError`, as the resolver above does.
So the first thing `ResolveComparison` does is route the key:
```go
if allowedOperations, isReserved := ReservedOps[key]; isReserved {
return r.resolveReservedKey(v, ctx, operation, key, allowedOperations)
}
return r.buildLabelComparison(v, ctx, operation, key)
```
#### Operator allowlists
Not every operator makes sense on every key, reserved or not (`name BETWEEN ...` does not). Declare what each accepts and check before building. `sample_entity` pairs each reserved key with its allowed operators:
```go
var ReservedOps = map[string]map[qbtypesv5.FilterOperator]struct{}{
"name": stringSearchOps(),
"created_at": numericRangeOps(),
"locked": boolOps(),
}
if _, allowed := allowedOperations[operation]; !allowed {
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
return ""
}
```
Non-reserved keys get allowlists too, usually one shared list since they are all shaped alike: a label lookup is a string match, so `created_at > '2025-01-01T00:00:00Z'` is fine but `team > infra` is rejected with an `AddError`. Dashboards' real instances of both are `ReservedOps` and `TagKeyOps` in [pkg/types/dashboardtypes](/pkg/types/dashboardtypes/list_filter.go).
#### JSON columns
Suppose `sample_entity` keeps `name` inside a `data` JSON column instead of a plain column. The resolver then builds the column expression with `v.Formatter.JSONExtractString`, which renders correctly on both dialects, and `name CONTAINS cpu` compiles (SQLite flavor) to:
```sql
json_extract("sample_entity"."data", '$.name') LIKE ? ESCAPE '\'
-- args: ["%cpu%"]
```
Dashboards stores name and description this way inside `dashboard.data`.
#### Relation tables
The label policy from above: say `sample_entity` labels live in `label`/`label_relation` join tables, so a label term becomes an `EXISTS` subquery. Build it on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. `team = infra` compiles to:
```sql
EXISTS (SELECT 1 FROM label_relation lr JOIN label l ON l.id = lr.label_id
WHERE lr.entity_id = sample_entity.id
AND LOWER(l.key) = LOWER(?) AND l.value = ?)
-- args: ["team", "infra"]
```
For a negative operator (`team != infra`), build the positive predicate and toggle `NotExists` on the outer builder, so rows without the label at all also match. Dashboards' tags follow this exact pattern over the shared `tag`/`tag_relation` tables.
## How to wire it in?
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code:
```go
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, sampleEntityFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(sampleentitytypes.ErrCodeSampleEntityListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}
```
Dashboards' real wrapper is [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go).
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
## Caveats
- This compiler is for the relational store only. Telemetry filters are a different pipeline; they stay on querybuilder's ClickHouse visitor.
- A `key REGEXP value` term parses, but no predicate builder implements it: `BuildStringOperation` rejects it with an error, since SQLite has no portable `REGEXP` (Postgres spells it `~`). A resolver may implement it itself for a dialect it controls.
- `has(...)` function calls and `search(...)` from the telemetry grammar are not implemented; they fall through to `ResolveFreeText` as literal text.

View File

@@ -21,4 +21,5 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
- [Service](service.md) - Managed service lifecycle with `factory.Service`
- [SQL](sql.md) - Database and SQL patterns
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`

View File

@@ -40,7 +40,73 @@ export interface AlertmanagertypesChannelDTO {
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfirmationDTO {
/**
* @type string
*/
dismissText?: string;
/**
* @type string
*/
okText?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelSlackActionDTO {
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
style?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
type: string;
/**
* @type string
*/
url?: string;
/**
* @type string
*/
value?: string;
}
export interface AlertmanagertypesChannelSlackFieldDTO {
/**
* @type boolean,null
*/
short?: boolean | null;
/**
* @type string
*/
title: string;
/**
* @type string
*/
value: string;
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
/**
* @type string
* @format password
@@ -50,6 +116,26 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
channel?: string;
/**
* @type string
*/
color?: string;
/**
* @type string
*/
fallback?: string;
/**
* @type array
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
/**
* @type string
*/
footer?: string;
/**
* @type string
*/
pretext?: string;
/**
* @type boolean,null
*/
@@ -62,6 +148,10 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
title?: string;
/**
* @type string
*/
titleLink?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {

View File

@@ -1,21 +1,15 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldKeysConfig = GetFieldsKeysParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldValuesConfig = GetFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,

View File

@@ -17,6 +17,7 @@ function InputWithLabel({
onChange,
className,
closeIcon,
disabled,
}: {
label: string;
initialValue?: string | number | null;
@@ -27,6 +28,7 @@ function InputWithLabel({
onChange: (value: string) => void;
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
@@ -53,6 +55,7 @@ function InputWithLabel({
type={type}
value={inputValue}
onChange={handleChange}
disabled={disabled}
name={label.toLowerCase()}
data-testid={`input-${label}`}
/>

View File

@@ -1,11 +1,18 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderField,
} from './queryBuilderFields.utils';
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
import { clearPreviousQuery } from './QueryV2/previousQuery.utils';
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
@@ -14,12 +21,18 @@ import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
import './QueryBuilderV2.styles.scss';
// Raw rows come from logs or spans; metrics only exist aggregated.
const RAW_QUERY_SIGNALS = [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
];
export const QueryBuilderV2 = memo(function QueryBuilderV2({
config,
panelType: newPanelType,
filterConfigs = {},
queryComponents,
isListViewPanel = false,
fieldsConfig,
allowedDataSources,
isRawQuery = false,
showOnlyWhereClause = false,
showTraceOperator = false,
version,
@@ -71,55 +84,48 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
};
}, []);
const isMultiQueryAllowed = useMemo(
() => !isListViewPanel || showTraceOperator,
[showTraceOperator, isListViewPanel],
const resolvedConfig = useMemo(
() =>
mergeQueryBuilderFieldsConfig(
isRawQuery ? RAW_QUERY_FIELDS : undefined,
fieldsConfig,
),
[isRawQuery, fieldsConfig],
);
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] =
useMemo(() => {
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: {
customKey: 'body',
customOp: OPERATORS.CONTAINS,
},
};
const additionalQueries = useMemo(
() =>
resolveQueryBuilderField(
QueryBuilderField.AdditionalQueries,
resolvedConfig,
),
[resolvedConfig],
);
return config;
}, []);
const formula = useMemo(
() => resolveQueryBuilderField(QueryBuilderField.Formula, resolvedConfig),
[resolvedConfig],
);
const listViewTracesFilterConfigs: QueryBuilderProps['filterConfigs'] =
useMemo(() => {
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: {
customKey: 'body',
customOp: OPERATORS.CONTAINS,
},
};
const isMultiQueryAllowed = useMemo(
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
[additionalQueries.hidden, showTraceOperator, isRawQuery],
);
return config;
}, []);
const queryDataSources = useMemo(
() => allowedDataSources ?? (isRawQuery ? RAW_QUERY_SIGNALS : undefined),
[allowedDataSources, isRawQuery],
);
const queryFilterConfigs = useMemo(() => {
if (isListViewPanel) {
return currentQuery.builder.queryData[0].dataSource === DataSource.TRACES
? listViewTracesFilterConfigs
: listViewLogFilterConfigs;
}
return filterConfigs;
}, [
isListViewPanel,
filterConfigs,
currentQuery.builder.queryData,
listViewLogFilterConfigs,
listViewTracesFilterConfigs,
]);
// What the editor renders. A single-query builder edits the first query alone, so
// the query list beside it must not advertise ones there is no way to reach.
const renderedQueries = useMemo(
() =>
isMultiQueryAllowed
? currentQuery.builder.queryData
: currentQuery.builder.queryData.slice(0, 1),
[isMultiQueryAllowed, currentQuery.builder.queryData],
);
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
if (
@@ -145,31 +151,46 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const shouldShowFooter = useMemo(
() =>
(!showOnlyWhereClause && !isListViewPanel) ||
(currentDataSource === DataSource.TRACES && showTraceOperator),
[isListViewPanel, showTraceOperator, showOnlyWhereClause, currentDataSource],
);
const showQueryList = useMemo(
() => (!showOnlyWhereClause && !isListViewPanel) || showTraceOperator,
[isListViewPanel, showOnlyWhereClause, showTraceOperator],
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
[isRawQuery, showOnlyWhereClause, showTraceOperator],
);
const showFormula = useMemo(() => {
if (formula.hidden) {
return false;
}
if (currentDataSource === DataSource.TRACES) {
return !isListViewPanel;
return !isRawQuery;
}
return true;
}, [isListViewPanel, currentDataSource]);
}, [formula.hidden, isRawQuery, currentDataSource]);
const showAddTraceOperator = useMemo(
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
// Nothing left to add means no footer at all, rather than an empty bar under the
// last query.
const shouldShowFooter = useMemo(
() =>
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
((!showOnlyWhereClause && !isRawQuery) ||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
[
additionalQueries.hidden,
showFormula,
showAddTraceOperator,
isRawQuery,
showTraceOperator,
showOnlyWhereClause,
currentDataSource,
],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): void => {
const target = e.target as HTMLElement | null;
@@ -199,8 +220,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
key={currentQuery.builder.queryData[0].queryName}
index={0}
query={currentQuery.builder.queryData[0]}
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={showTraceOperator}
hasTraceOperator={hasTraceOperator}
@@ -208,7 +229,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isAvailableToDisable={false}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
@@ -216,14 +237,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
savePreviousQuery={savePreviousQuery}
/>
) : (
currentQuery.builder.queryData.map((query, index) => (
renderedQueries.map((query, index) => (
<QueryV2
ref={containerRef}
key={query.queryName}
index={index}
query={query}
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
version={version}
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
@@ -231,7 +252,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
hasTraceOperator={hasTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
@@ -251,14 +272,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
return (
<div key={formula.queryName} className="qb-formula">
<Formula
filterConfigs={filterConfigs}
query={query}
formula={formula}
index={index}
isAdditionalFilterEnable={false}
isQBV2
/>
<Formula query={query} formula={formula} index={index} isQBV2 />
</div>
);
})}
@@ -267,8 +281,13 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{shouldShowFooter && (
<QueryFooter
showAddQuery={!additionalQueries.hidden}
showAddFormula={showFormula}
isAddFormulaDisabled={formula.disabled}
addFormulaDisabledReason={formula.reason}
addNewBuilderQuery={addNewBuilderQuery}
isAddQueryDisabled={additionalQueries.disabled}
addQueryDisabledReason={additionalQueries.reason}
addNewFormula={addNewFormula}
addTraceOperator={addTraceOperator}
showAddTraceOperator={showAddTraceOperator}
@@ -277,7 +296,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{hasTraceOperator && (
<TraceOperator
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
fieldsConfig={resolvedConfig}
traceOperator={traceOperator as IBuilderTraceOperator}
/>
)}
@@ -285,7 +305,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{showQueryList && (
<div className="query-names-section">
{currentQuery.builder.queryData.map((query) => (
{renderedQueries.map((query) => (
<div key={query.queryName} className="query-name">
{query.queryName}
</div>

View File

@@ -23,6 +23,11 @@
align-items: center;
justify-content: center;
gap: var(--margin-2);
&--disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -14,6 +15,16 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
QueryBuilderField,
QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
@@ -22,34 +33,37 @@ import './QueryAddOns.styles.scss';
interface AddOn {
icon: React.ReactNode;
label: string;
key: string;
key: QueryBuilderField;
description?: string;
docLink?: string;
}
const ADD_ONS_KEYS = {
GROUP_BY: 'group_by',
HAVING: 'having',
ORDER_BY: 'order_by',
LIMIT: 'limit',
LEGEND_FORMAT: 'legend_format',
REDUCE_TO: 'reduce_to',
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
type NonAddOnField =
| QueryBuilderField.Aggregation
| QueryBuilderField.StepInterval
| QueryBuilderField.Functions
| QueryBuilderField.Formula
| QueryBuilderField.AdditionalQueries;
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
Record<QueryBuilderField, string>,
NonAddOnField
> = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.Having]: 'having.expression',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
};
const ADD_ONS_KEYS_TO_QUERY_PATH = {
[ADD_ONS_KEYS.GROUP_BY]: 'groupBy',
[ADD_ONS_KEYS.HAVING]: 'having.expression',
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
[ADD_ONS_KEYS.LIMIT]: 'limit',
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
};
const ADD_ONS = [
const ADD_ONS: AddOn[] = [
{
icon: <BarChart size={14} />,
label: 'Group By',
key: ADD_ONS_KEYS.GROUP_BY,
key: QueryBuilderField.GroupBy,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
@@ -57,7 +71,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Having',
key: ADD_ONS_KEYS.HAVING,
key: QueryBuilderField.Having,
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
@@ -66,7 +80,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Order By',
key: ADD_ONS_KEYS.ORDER_BY,
key: QueryBuilderField.OrderBy,
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
@@ -75,7 +89,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Limit',
key: ADD_ONS_KEYS.LIMIT,
key: QueryBuilderField.Limit,
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
@@ -84,7 +98,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Legend format',
key: ADD_ONS_KEYS.LEGEND_FORMAT,
key: QueryBuilderField.Legend,
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
@@ -92,10 +106,10 @@ const ADD_ONS = [
},
];
const REDUCE_TO = {
const REDUCE_TO: AddOn = {
icon: <ScrollText size={14} />,
label: 'Reduce to',
key: ADD_ONS_KEYS.REDUCE_TO,
key: QueryBuilderField.ReduceTo,
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
@@ -154,26 +168,26 @@ function TooltipContent({
function QueryAddOns({
query,
version,
isListViewPanel,
isRawQuery,
showReduceTo,
panelType,
index,
fieldsConfig,
isForTraceOperator = false,
}: {
query: IBuilderQuery;
version: string;
isListViewPanel: boolean;
isRawQuery: boolean;
showReduceTo: boolean;
panelType: PANEL_TYPES | null;
index: number;
fieldsConfig?: QueryBuilderFieldsConfig;
isForTraceOperator?: boolean;
}): JSX.Element {
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
const initializedRef = useRef(false);
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
const prevAvailableKeysRef = useRef<Set<QueryBuilderField> | null>(null);
const { handleChangeQueryData } = useQueryOperations({
index,
@@ -184,40 +198,62 @@ function QueryAddOns({
const { handleSetQueryData } = useQueryBuilder();
useEffect(() => {
if (isListViewPanel) {
setAddOns([]);
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
setSelectedViews([
ADD_ONS.find((addOn) => addOn.key === ADD_ONS_KEYS.ORDER_BY) as AddOn,
]);
return;
}
let filteredAddOns: AddOn[];
if (panelType === PANEL_TYPES.VALUE) {
// Filter out all add-ons except legend format
filteredAddOns = ADD_ONS.filter(
(addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT,
);
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
} else if (query.dataSource === DataSource.METRICS) {
// Group by for metrics is offered by MetricsAggregateSection instead.
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
} else {
filteredAddOns = Object.values(ADD_ONS);
if (query.dataSource === DataSource.METRICS) {
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
filteredAddOns = filteredAddOns.filter(
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
);
}
addOns = [...ADD_ONS];
}
if (showReduceTo) {
filteredAddOns = [...filteredAddOns, REDUCE_TO];
}
setAddOns(filteredAddOns);
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
}, [panelType, query.dataSource, showReduceTo]);
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
const resolvedFields = useMemo(
() =>
resolveQueryBuilderFields(
supportedAddOns.map((addOn) => addOn.key),
mergeQueryBuilderFieldsConfig(
isRawQuery ? RAW_QUERY_FIELDS : undefined,
fieldsConfig,
),
),
[supportedAddOns, fieldsConfig, isRawQuery],
);
const offeredAddOns = useMemo(
() =>
supportedAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.hidden),
[supportedAddOns, resolvedFields],
);
const pinnedAddOns = useMemo(
() => offeredAddOns.filter((addOn) => resolvedFields.get(addOn.key)?.pinned),
[offeredAddOns, resolvedFields],
);
const togglableAddOns = useMemo(
() => offeredAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.pinned),
[offeredAddOns, resolvedFields],
);
const isPinned = useCallback(
(key: QueryBuilderField): boolean => Boolean(resolvedFields.get(key)?.pinned),
[resolvedFields],
);
const isDisabled = useCallback(
(key: QueryBuilderField): boolean =>
Boolean(resolvedFields.get(key)?.disabled),
[resolvedFields],
);
useEffect(() => {
const availableAddOnKeys = new Set(offeredAddOns.map((a) => a.key));
const previousKeys = prevAvailableKeysRef.current;
const hasAvailabilityItemsChanged =
previousKeys !== null &&
@@ -231,27 +267,39 @@ function QueryAddOns({
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
.map(([key]) => key as QueryBuilderField),
);
// Initial seeding from query values on mount
// Initial seeding from query values on mount. A disabled field never opens.
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
offeredAddOns.filter((addOn) => {
const resolved = resolvedFields.get(addOn.key);
return (
resolved?.pinned ||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
);
}),
);
return;
}
setSelectedViews((prev) =>
prev.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
),
);
}, [panelType, isListViewPanel, query, showReduceTo]);
setSelectedViews((prev) => {
const kept = prev.filter((view) => availableAddOnKeys.has(view.key));
const reopenedPinned = pinnedAddOns.filter(
(addOn) => !kept.some((view) => view.key === addOn.key),
);
return [...kept, ...reopenedPinned];
});
}, [offeredAddOns, pinnedAddOns, query]);
const handleOptionClick = (clickedAddOn: AddOn): void => {
if (isDisabled(clickedAddOn.key)) {
return;
}
const isAlreadySelected = selectedViews.some(
(view) => view.key === clickedAddOn.key,
);
@@ -265,7 +313,7 @@ function QueryAddOns({
// and existing group-by keys, prefill the legend using all group-by keys.
// This keeps existing custom legends intact and only helps seed a sensible default.
if (
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
clickedAddOn.key === QueryBuilderField.Legend &&
isEmpty(query?.legend) &&
Array.isArray(query.groupBy) &&
query.groupBy.length > 0
@@ -310,9 +358,16 @@ function QueryAddOns({
[handleSetQueryData, index, query],
);
const handleRemoveView = useCallback((key: string): void => {
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
}, []);
const handleRemoveView = useCallback(
(key: QueryBuilderField): void => {
if (isPinned(key)) {
return;
}
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
},
[isPinned],
);
const handleChangeQueryLegend = useCallback(
(value: string) => {
@@ -341,7 +396,7 @@ function QueryAddOns({
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
<div className="selected-add-ons-content">
{selectedViews.find((view) => view.key === 'group_by') && (
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
<div className="add-on-content" data-testid="group-by-content">
<div className="periscope-input-with-label">
<Tooltip
@@ -369,15 +424,17 @@ function QueryAddOns({
onChange={handleChangeGroupByKeys}
/>
</div>
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView('group_by')}
/>
{!isPinned(QueryBuilderField.GroupBy) && (
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView(QueryBuilderField.GroupBy)}
/>
)}
</div>
</div>
)}
{selectedViews.find((view) => view.key === 'having') && (
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
<div className="add-on-content" data-testid="having-content">
<div className="periscope-input-with-label">
<Tooltip
@@ -397,11 +454,7 @@ function QueryAddOns({
</Tooltip>
<div className="input">
<HavingFilter
onClose={(): void => {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'having'),
);
}}
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
onChange={handleChangeHaving}
queryData={query}
/>
@@ -409,7 +462,7 @@ function QueryAddOns({
</div>
</div>
)}
{selectedViews.find((view) => view.key === 'limit') && (
{selectedViews.find((view) => view.key === QueryBuilderField.Limit) && (
<div className="add-on-content" data-testid="limit-content">
<InputWithLabel
label="Limit"
@@ -417,16 +470,12 @@ function QueryAddOns({
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"
onClose={(): void => {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'limit'),
);
}}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>
</div>
)}
{selectedViews.find((view) => view.key === 'order_by') && (
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
<div className="add-on-content" data-testid="order-by-content">
<div className="periscope-input-with-label">
<Tooltip
@@ -449,22 +498,22 @@ function QueryAddOns({
entityVersion={version}
query={query}
onChange={handleChangeOrderByKeys}
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
isNewQueryV2
/>
</div>
{!isListViewPanel && (
{!isPinned(QueryBuilderField.OrderBy) && (
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView('order_by')}
onClick={(): void => handleRemoveView(QueryBuilderField.OrderBy)}
/>
)}
</div>
</div>
)}
{selectedViews.find((view) => view.key === 'reduce_to') &&
{selectedViews.find((view) => view.key === QueryBuilderField.ReduceTo) &&
showReduceTo && (
<div className="add-on-content" data-testid="reduce-to-content">
<div className="periscope-input-with-label">
@@ -487,27 +536,25 @@ function QueryAddOns({
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
</div>
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView('reduce_to')}
/>
{!isPinned(QueryBuilderField.ReduceTo) && (
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView(QueryBuilderField.ReduceTo)}
/>
)}
</div>
</div>
)}
{selectedViews.find((view) => view.key === 'legend_format') && (
{selectedViews.find((view) => view.key === QueryBuilderField.Legend) && (
<div className="add-on-content" data-testid="legend-format-content">
<InputWithLabel
label="Legend format"
placeholder="Write legend format"
onChange={handleChangeQueryLegend}
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
onClose={(): void => {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'legend_format'),
);
}}
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
closeIcon={<ChevronUp size={16} />}
/>
</div>
@@ -520,42 +567,49 @@ function QueryAddOns({
className="add-ons-tabs"
value={selectedViews.map((view) => view.key)}
onChange={(newKeys: string[]): void => {
const oldKeys = selectedViews.map((view) => view.key);
const oldKeys: string[] = selectedViews.map((view) => view.key);
const toggledKey =
newKeys.find((k) => !oldKeys.includes(k)) ??
oldKeys.find((k) => !newKeys.includes(k));
newKeys.find((key) => !oldKeys.includes(key)) ??
oldKeys.find((key) => !newKeys.includes(key));
if (!toggledKey) {
return;
}
const clickedAddOn = addOns.find((a) => a.key === toggledKey);
const clickedAddOn = togglableAddOns.find((a) => a.key === toggledKey);
if (clickedAddOn) {
handleOptionClick(clickedAddOn);
}
}}
items={addOns.map((addOn) => ({
value: addOn.key,
label: (
<Tooltip
title={
<TooltipContent
label={addOn.label}
description={addOn.description}
docLink={addOn.docLink}
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<span
className="add-on-tab-title"
data-testid={`query-add-on-${addOn.key}`}
items={togglableAddOns.map((addOn) => {
const resolved = resolvedFields.get(addOn.key);
return {
value: addOn.key,
label: (
<Tooltip
title={
<TooltipContent
label={addOn.label}
description={resolved?.reason ?? addOn.description}
docLink={resolved?.disabled ? undefined : addOn.docLink}
/>
}
placement="top"
mouseEnterDelay={0.5}
>
{addOn.icon}
{addOn.label}
</span>
</Tooltip>
),
}))}
<span
className={cx('add-on-tab-title', {
'add-on-tab-title--disabled': resolved?.disabled,
})}
aria-disabled={resolved?.disabled}
data-testid={`query-add-on-${addOn.key}`}
>
{addOn.icon}
{addOn.label}
</span>
</Tooltip>
),
};
})}
/>
</div>
);

View File

@@ -8,6 +8,12 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
QueryBuilderField,
QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -18,24 +24,32 @@ function QueryAggregationOptions({
onAggregationIntervalChange,
onChange,
queryData,
fieldsConfig,
}: {
dataSource: DataSource;
panelType?: string;
onAggregationIntervalChange: (value: number) => void;
onChange?: (value: string) => void;
queryData: IBuilderQuery | IBuilderTraceOperator;
fieldsConfig?: QueryBuilderFieldsConfig;
}): JSX.Element {
const showAggregationInterval = useMemo(() => {
const stepInterval = useMemo(() => {
if (panelType === PANEL_TYPES.VALUE) {
return false;
return { hidden: true, disabled: false, reason: undefined };
}
if (dataSource === DataSource.TRACES || dataSource === DataSource.LOGS) {
return !(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE);
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}
return true;
}, [dataSource, panelType]);
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
}, [dataSource, panelType, fieldsConfig]);
const handleAggregationIntervalChange = (value: string): void => {
onAggregationIntervalChange(Number(value));
@@ -57,22 +71,24 @@ function QueryAggregationOptions({
}
/>
{showAggregationInterval && (
{!stepInterval.hidden && (
<div className="query-aggregation-interval">
<Tooltip
title={
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
stepInterval.reason ?? (
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
)
}
placement="top"
>
@@ -92,6 +108,7 @@ function QueryAggregationOptions({
placeholder="Auto"
type="number"
onChange={handleAggregationIntervalChange}
disabled={stepInterval.disabled}
labelAfter
/>
</div>
@@ -105,6 +122,7 @@ function QueryAggregationOptions({
QueryAggregationOptions.defaultProps = {
panelType: null,
onChange: undefined,
fieldsConfig: undefined,
};
export default QueryAggregationOptions;

View File

@@ -17,13 +17,13 @@ function TraceOperatorSection({
const { currentQuery, panelType } = useQueryBuilder();
const showTraceOperatorWarning = useMemo(() => {
const isListViewPanel =
const isRawQueryPanel =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const hasMultipleQueries = currentQuery.builder.queryData.length > 1;
const hasTraceOperator =
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0;
return isListViewPanel && hasMultipleQueries && !hasTraceOperator;
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
}, [
currentQuery?.builder?.queryData,
currentQuery?.builder?.queryTraceOperator,
@@ -77,50 +77,74 @@ export default function QueryFooter({
addNewBuilderQuery,
addNewFormula,
addTraceOperator,
showAddQuery = true,
showAddFormula = true,
showAddTraceOperator = false,
isAddQueryDisabled = false,
addQueryDisabledReason,
isAddFormulaDisabled = false,
addFormulaDisabledReason,
}: {
addNewBuilderQuery: () => void;
addNewFormula: () => void;
addTraceOperator?: () => void;
showAddTraceOperator: boolean;
showAddQuery?: boolean;
showAddFormula?: boolean;
isAddQueryDisabled?: boolean;
addQueryDisabledReason?: string;
isAddFormulaDisabled?: boolean;
addFormulaDisabledReason?: string;
}): JSX.Element {
return (
<div className="qb-footer">
<div className="qb-footer-container">
<div className="qb-add-new-query">
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
<Button
className="add-new-query-button periscope-btn "
icon={<Plus size={16} />}
onClick={addNewBuilderQuery}
/>
</Tooltip>
</div>
{showAddQuery && (
<div className="qb-add-new-query">
<Tooltip
title={
addQueryDisabledReason ?? (
<div style={{ textAlign: 'center' }}>Add New Query</div>
)
}
>
<Button
className="add-new-query-button periscope-btn "
data-testid="add-new-query-button"
icon={<Plus size={16} />}
onClick={addNewBuilderQuery}
disabled={isAddQueryDisabled}
/>
</Tooltip>
</div>
)}
{showAddFormula && (
<div className="qb-add-formula">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
addFormulaDisabledReason ?? (
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
)
}
>
<Button
className="add-formula-button periscope-btn "
data-testid="add-formula-button"
icon={<Sigma size={16} />}
onClick={addNewFormula}
disabled={isAddFormulaDisabled}
>
Add Formula
</Button>

View File

@@ -20,6 +20,13 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -31,8 +38,7 @@ export const QueryV2 = forwardRef(function QueryV2(
index,
queryVariant,
query,
filterConfigs,
isListViewPanel = false,
isRawQuery = false,
showTraceOperator = false,
hasTraceOperator = false,
version,
@@ -43,6 +49,8 @@ export const QueryV2 = forwardRef(function QueryV2(
signalSourceChangeEnabled = false,
queriesCount = 1,
savePreviousQuery = false,
fieldsConfig,
allowedDataSources,
}: QueryProps & {
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
@@ -53,7 +61,7 @@ export const QueryV2 = forwardRef(function QueryV2(
): JSX.Element {
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
const hasQueryFunctions = query?.functions?.length > 0;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -66,8 +74,7 @@ export const QueryV2 = forwardRef(function QueryV2(
} = useQueryOperations({
index,
query,
filterConfigs,
isListViewPanel,
isRawQuery,
entityVersion: version,
savePreviousQuery,
});
@@ -99,14 +106,31 @@ export const QueryV2 = forwardRef(function QueryV2(
[dataSource, builderQueryType],
);
const resolvedConfig = useMemo(
() =>
mergeQueryBuilderFieldsConfig(
isRawQuery ? RAW_QUERY_FIELDS : undefined,
fieldsConfig,
),
[isRawQuery, fieldsConfig],
);
const aggregation = useMemo(
() => resolveQueryBuilderField(QueryBuilderField.Aggregation, resolvedConfig),
[resolvedConfig],
);
const functions = useMemo(
() => resolveQueryBuilderField(QueryBuilderField.Functions, resolvedConfig),
[resolvedConfig],
);
const showInlineQuerySearch = useMemo(() => {
if (!showTraceOperator) {
return false;
}
return (
dataSource === DataSource.TRACES && (hasTraceOperator || isListViewPanel)
);
}, [hasTraceOperator, isListViewPanel, showTraceOperator, dataSource]);
return dataSource === DataSource.TRACES && (hasTraceOperator || isRawQuery);
}, [hasTraceOperator, isRawQuery, showTraceOperator, dataSource]);
const handleChangeAggregateEvery = useCallback(
(value: IBuilderQuery['stepInterval']) => {
@@ -149,12 +173,15 @@ export const QueryV2 = forwardRef(function QueryV2(
hasTraceOperator={hasTraceOperator}
isMetricsDataSource={dataSource === DataSource.METRICS}
showFunctions={
(version && version === ENTITY_VERSION_V4) ||
query.dataSource === DataSource.LOGS ||
query.dataSource === DataSource.METRICS ||
showFunctions ||
false
!functions.hidden &&
((version && version === ENTITY_VERSION_V4) ||
query.dataSource === DataSource.LOGS ||
query.dataSource === DataSource.METRICS ||
hasQueryFunctions ||
false)
}
areFunctionsDisabled={functions.disabled}
functionsDisabledReason={functions.reason}
isCollapsed={isCollapsed}
showTraceOperator={showTraceOperator}
entityType="query"
@@ -167,7 +194,8 @@ export const QueryV2 = forwardRef(function QueryV2(
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
showDeleteButton={false}
showCloneOption={false}
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
allowedDataSources={allowedDataSources}
index={index}
queryVariant={queryVariant}
onChangeDataSource={handleChangeDataSource}
@@ -267,7 +295,7 @@ export const QueryV2 = forwardRef(function QueryV2(
</div>
{!showOnlyWhereClause &&
!isListViewPanel &&
!aggregation.hidden &&
!(hasTraceOperator && dataSource === DataSource.TRACES) &&
dataSource !== DataSource.METRICS && (
<QueryAggregation
@@ -277,6 +305,7 @@ export const QueryV2 = forwardRef(function QueryV2(
onAggregationIntervalChange={handleChangeAggregateEvery}
onChange={handleChangeAggregation}
queryData={query}
fieldsConfig={fieldsConfig}
/>
)}
@@ -297,9 +326,10 @@ export const QueryV2 = forwardRef(function QueryV2(
index={index}
query={query}
version="v3"
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
showReduceTo={showReduceTo}
panelType={panelType}
fieldsConfig={fieldsConfig}
/>
)}
</div>

View File

@@ -11,6 +11,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderFieldsConfig } from '../../queryBuilderFields.types';
import QueryAddOns from '../QueryAddOns/QueryAddOns';
import QueryAggregation from '../QueryAggregation/QueryAggregation';
import TraceOperatorEditor from './TraceOperatorEditor';
@@ -19,10 +20,12 @@ import './TraceOperator.styles.scss';
export default function TraceOperator({
traceOperator,
isListViewPanel = false,
isRawQuery = false,
fieldsConfig,
}: {
traceOperator: IBuilderTraceOperator;
isListViewPanel?: boolean;
isRawQuery?: boolean;
fieldsConfig?: QueryBuilderFieldsConfig;
}): JSX.Element {
const { panelType, removeTraceOperator } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
@@ -58,12 +61,12 @@ export default function TraceOperator({
);
return (
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
<div className="qb-trace-operator-container">
<div
className={cx(
'qb-trace-operator-label-with-input',
!isListViewPanel && 'qb-trace-operator-arrow',
!isRawQuery && 'qb-trace-operator-arrow',
)}
>
<Typography.Text className="label">Trace Operator</Typography.Text>
@@ -76,9 +79,9 @@ export default function TraceOperator({
</div>
</div>
{!isListViewPanel && (
{!isRawQuery && (
<div className="qb-trace-operator-aggregation-container">
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
<div className={cx(!isRawQuery && 'qb-trace-operator-arrow')}>
<QueryAggregation
dataSource={DataSource.TRACES}
key={`query-search-${traceOperator.queryName}`}
@@ -86,12 +89,13 @@ export default function TraceOperator({
onAggregationIntervalChange={handleChangeAggregateEvery}
onChange={handleChangeAggregation}
queryData={traceOperator}
fieldsConfig={fieldsConfig}
/>
</div>
<div
className={cx(
'qb-trace-operator-add-ons-container',
!isListViewPanel && 'qb-trace-operator-arrow',
!isRawQuery && 'qb-trace-operator-arrow',
)}
>
<QueryAddOns
@@ -99,9 +103,10 @@ export default function TraceOperator({
query={traceOperator}
version="v3"
isForTraceOperator
isListViewPanel={false}
isRawQuery={false}
showReduceTo={false}
panelType={panelType}
fieldsConfig={fieldsConfig}
/>
</div>
</div>

View File

@@ -142,7 +142,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
isMetricsDataSource: false,
operators: [],
spaceAggregationOptions: [],
listOfAdditionalFilters: [],
handleChangeOperator: jest.fn(),
handleSpaceAggregationChange: jest.fn(),
handleChangeAggregatorAttribute: jest.fn(),
@@ -152,7 +151,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
jest.fn() as unknown as ReturnType<UseQueryOperations>['handleChangeQueryData'],
handleChangeFormulaData: jest.fn(),
handleQueryFunctionsUpdates: handleQueryFunctionsUpdatesMock,
listOfAdditionalFormulaFilters: [],
});
});

View File

@@ -95,7 +95,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo
panelType={PANEL_TYPES.VALUE}
index={0}
@@ -119,7 +119,7 @@ describe('QueryAddOns', () => {
groupBy: ['service.name'],
})}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -135,7 +135,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel
isRawQuery
showReduceTo={false}
panelType={PANEL_TYPES.LIST}
index={0}
@@ -151,7 +151,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery({ limit: 5 })}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -176,7 +176,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -195,7 +195,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -211,7 +211,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery({ reduceTo: ReduceOperators.SUM })}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -234,7 +234,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -286,7 +286,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -314,7 +314,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}

View File

@@ -0,0 +1,46 @@
import { render, screen } from 'tests/test-utils';
import QueryFooter from '../QueryV2/QueryFooter/QueryFooter';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: { builder: { queryData: unknown[] } };
panelType: string;
} => ({
currentQuery: { builder: { queryData: [] } },
panelType: 'time_series',
}),
}));
const noop = (): void => {};
describe('QueryFooter', () => {
it('offers both buttons by default', () => {
render(
<QueryFooter
addNewBuilderQuery={noop}
addNewFormula={noop}
showAddTraceOperator={false}
/>,
);
expect(screen.getByTestId('add-new-query-button')).toBeInTheDocument();
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
});
// A kind whose request takes a single query (Heatmap) hides the button outright
// rather than disabling it — a query it adds is one the builder cannot render.
it('drops the Add New Query button when the caller withholds it', () => {
render(
<QueryFooter
addNewBuilderQuery={noop}
addNewFormula={noop}
showAddQuery={false}
showAddTraceOperator={false}
/>,
);
expect(screen.queryByTestId('add-new-query-button')).not.toBeInTheDocument();
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
import { QueryBuilderField } from '../queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderField,
resolveQueryBuilderFields,
} from '../queryBuilderFields.utils';
const SUPPORTED = [
QueryBuilderField.GroupBy,
QueryBuilderField.Having,
QueryBuilderField.OrderBy,
QueryBuilderField.Limit,
QueryBuilderField.Legend,
];
describe('resolveQueryBuilderField', () => {
it('leaves an unconfigured field available', () => {
expect(resolveQueryBuilderField(QueryBuilderField.Having)).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
});
});
it('hides a field configured hidden', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
[QueryBuilderField.Having]: { state: 'hidden' },
});
expect(resolved.hidden).toBe(true);
expect(resolved.disabled).toBe(false);
});
it('carries the reason through on a disabled field', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
[QueryBuilderField.Having]: {
state: 'disabled',
reason: 'Having filters aggregated results.',
},
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: true,
reason: 'Having filters aggregated results.',
pinned: false,
});
});
it('pins a field configured pinned', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.OrderBy, {
[QueryBuilderField.OrderBy]: { state: 'pinned' },
});
expect(resolved.pinned).toBe(true);
expect(resolved.hidden).toBe(false);
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },
});
expect([resolved.hidden, resolved.disabled, resolved.pinned]).toStrictEqual([
false,
true,
false,
]);
});
});
describe('resolveQueryBuilderFields', () => {
it('resolves every supported field and nothing else', () => {
const resolved = resolveQueryBuilderFields(SUPPORTED);
expect([...resolved.keys()]).toStrictEqual(SUPPORTED);
});
it('cannot widen beyond what the builder supports', () => {
const resolved = resolveQueryBuilderFields([QueryBuilderField.Legend], {
[QueryBuilderField.ReduceTo]: { state: 'pinned' },
});
expect(resolved.has(QueryBuilderField.ReduceTo)).toBe(false);
});
});
describe('mergeQueryBuilderFieldsConfig', () => {
it('returns the override when there is no baseline', () => {
const override = { [QueryBuilderField.Limit]: { state: 'hidden' } } as const;
expect(mergeQueryBuilderFieldsConfig(undefined, override)).toBe(override);
});
it('returns the baseline when there is no override', () => {
expect(mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, undefined)).toBe(
RAW_QUERY_FIELDS,
);
});
it('lets the override win per field, leaving the rest of the baseline intact', () => {
const merged = mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, {
[QueryBuilderField.Having]: { state: 'disabled', reason: 'no aggregation' },
});
expect(merged?.[QueryBuilderField.Having]).toStrictEqual({
state: 'disabled',
reason: 'no aggregation',
});
expect(merged?.[QueryBuilderField.GroupBy]).toStrictEqual({
state: 'hidden',
});
expect(merged?.[QueryBuilderField.OrderBy]).toStrictEqual({
state: 'pinned',
});
});
});
describe('RAW_QUERY_FIELDS', () => {
it('reduces an aggregate surface to a pinned order by', () => {
const resolved = resolveQueryBuilderFields(SUPPORTED, RAW_QUERY_FIELDS);
const visible = [...resolved.entries()]
.filter(([, field]) => !field.hidden)
.map(([key]) => key);
expect(visible).toStrictEqual([QueryBuilderField.OrderBy]);
expect(resolved.get(QueryBuilderField.OrderBy)?.pinned).toBe(true);
});
it('leaves additional queries alone, so trace matching still allows several', () => {
expect(
resolveQueryBuilderField(
QueryBuilderField.AdditionalQueries,
RAW_QUERY_FIELDS,
).hidden,
).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
/**
* Everything the query builder can surface.
*
* The per-query values double as the add-on identities the builder renders
* (`data-testid="query-add-on-<value>"`), so they are part of the DOM contract and must
* not be renamed to match the member names.
*/
export enum QueryBuilderField {
// Per query
Aggregation = 'aggregation',
StepInterval = 'step_interval',
Functions = 'functions',
GroupBy = 'group_by',
Having = 'having',
OrderBy = 'order_by',
Limit = 'limit',
Legend = 'legend_format',
ReduceTo = 'reduce_to',
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',
}
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
export type QueryBuilderFieldRule =
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };
/**
* A caller's narrowing of the builder's surface. The builder works out which fields suit
* the current data source and panel type first; this can only take away from that set.
*/
export type QueryBuilderFieldsConfig = Partial<
Record<QueryBuilderField, QueryBuilderFieldRule>
>;

View File

@@ -0,0 +1,92 @@
import {
QueryBuilderField,
QueryBuilderFieldRule,
QueryBuilderFieldsConfig,
} from './queryBuilderFields.types';
export interface ResolvedQueryBuilderField {
hidden: boolean;
disabled: boolean;
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
}
const AVAILABLE: ResolvedQueryBuilderField = {
hidden: false,
disabled: false,
pinned: false,
};
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':
return {
hidden: false,
disabled: true,
reason: rule.reason,
pinned: false,
};
case 'pinned':
return { hidden: false, disabled: false, pinned: true };
default:
return AVAILABLE;
}
}
export function resolveQueryBuilderField(
field: QueryBuilderField,
config?: QueryBuilderFieldsConfig,
): ResolvedQueryBuilderField {
const rule = config?.[field];
return rule ? fromRule(rule) : AVAILABLE;
}
/**
* Fields absent from `supported` are hidden whatever the config says, so a config can
* only ever take away.
*/
export function resolveQueryBuilderFields(
supported: readonly QueryBuilderField[],
config?: QueryBuilderFieldsConfig,
): Map<QueryBuilderField, ResolvedQueryBuilderField> {
return new Map(
supported.map((field) => [field, resolveQueryBuilderField(field, config)]),
);
}
/**
* The surface a raw-row builder starts from, layered under a caller's own config.
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
* queries when trace matching is on. Omit rather than Partial, so a field added to the
* enum has to be placed on one side.
*/
export const RAW_QUERY_FIELDS: Omit<
Record<QueryBuilderField, QueryBuilderFieldRule>,
QueryBuilderField.AdditionalQueries
> = {
[QueryBuilderField.Aggregation]: { state: 'hidden' },
[QueryBuilderField.StepInterval]: { state: 'hidden' },
[QueryBuilderField.Functions]: { state: 'hidden' },
[QueryBuilderField.GroupBy]: { state: 'hidden' },
[QueryBuilderField.Having]: { state: 'hidden' },
[QueryBuilderField.Limit]: { state: 'hidden' },
[QueryBuilderField.Legend]: { state: 'hidden' },
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
[QueryBuilderField.Formula]: { state: 'hidden' },
[QueryBuilderField.OrderBy]: { state: 'pinned' },
};
export function mergeQueryBuilderFieldsConfig(
baseline: QueryBuilderFieldsConfig | undefined,
override: QueryBuilderFieldsConfig | undefined,
): QueryBuilderFieldsConfig | undefined {
if (!baseline) {
return override;
}
return override ? { ...baseline, ...override } : baseline;
}

View File

@@ -15,7 +15,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import useActiveQueryIndex from './useActiveQueryIndex';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';

View File

@@ -56,6 +56,57 @@ export function mockFieldsValuesAPI(response: {
);
}
/**
* Records every request the AI observability values endpoint receives, so a test
* can assert both the routing and the query params it was called with.
*/
export function mockAIObservabilityFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
}): { requests: URLSearchParams[] } {
const requests: URLSearchParams[] = [];
server.use(
rest.get(
'http://localhost/api/v1/ai_observability/fields/values',
(req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
},
},
}),
);
},
),
);
return { requests };
}
/** Fails the test if the signal-wide values endpoint is hit at all. */
export function forbidFieldsValuesAPI(): { called: boolean } {
const state = { called: false };
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) => {
state.called = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
return state;
}
export function mockFieldsValuesAPILoading(): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>

View File

@@ -16,7 +16,7 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from '../useActiveQueryIndex';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';

View File

@@ -0,0 +1,81 @@
import { screen, waitFor } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
forbidFieldsValuesAPI,
mockAIObservabilityFieldsValuesAPI,
mockFieldsValuesAPI,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - AI observability routing', () => {
it('reads values from the AI observability endpoint and never the signal-wide one', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai', 'anthropic'],
});
const fieldsEndpoint = forbidFieldsValuesAPI();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('openai')).resolves.toBeInTheDocument();
expect(screen.getByText('anthropic')).toBeInTheDocument();
expect(fieldsEndpoint.called).toBe(false);
expect(aiEndpoint.requests).toHaveLength(1);
});
it('forwards the filter key and the time range to the AI observability endpoint', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByText('openai');
const params = aiEndpoint.requests[0];
expect(params.get('name')).toBe(DEFAULT_FILTER.attributeKey.key);
expect(params.get('startUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.startUnixMilli),
);
expect(params.get('endUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
);
});
it('keeps non-AI sources on the signal-wide endpoint', async () => {
mockFieldsValuesAPI({ stringValues: ['production'] });
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['should-not-be-used'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('production')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiEndpoint.requests).toHaveLength(0));
});
});

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldValuesConfig } from 'api/querySuggestions/types';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
interface UseFieldValuesProps {
@@ -42,32 +43,43 @@ export function useFieldValues({
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const { data, isLoading, isFetching } = useGetFieldsValues(
{
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
},
{
query: {
enabled,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
},
},
);
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
// The AI values endpoint is already gen_ai-scoped: no signal, no source.
const fieldValuesConfig: FieldValuesConfig = isAIObservability
? {
name: filter.attributeKey.key,
searchText,
existingQuery,
startUnixMilli,
endUnixMilli,
}
: {
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
};
const {
data: values,
isLoading,
isFetching,
} = useFieldValuesSuggestion(fieldValuesConfig, builderQueryType, { enabled });
const relatedValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -78,10 +90,9 @@ export function useFieldValues({
value !== null && value !== undefined && value !== '',
) || []
);
}, [data]);
}, [values]);
const allValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -101,7 +112,7 @@ export function useFieldValues({
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues, ...boolValues];
}, [data]);
}, [values]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Collapse } from 'antd';
import { Undo2 } from '@signozhq/icons';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -39,7 +39,7 @@ function Duration({
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
source?: QuickFiltersSource;
source: QuickFiltersSource;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] =
useState<
@@ -52,26 +52,11 @@ function Duration({
filter.defaultOpen ? 'durationNano' : '',
]);
const {
currentQuery,
redirectWithQueryBuilderData,
lastUsedQuery,
panelType,
} = useQueryBuilder();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
const activeQueryIndex = useActiveQueryIndex(source);
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {

View File

@@ -35,6 +35,7 @@ import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
@@ -113,14 +114,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// AI observability builds a single query in the row-level views, so there is
// no query for the selector to switch between.
const isAIObservabilityRowView =
source === QuickFiltersSource.AI_OBSERVABILITY &&
(isListView || panelType === PANEL_TYPES.TRACE);
const activeQueryIndex = useActiveQueryIndex(source);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
@@ -167,9 +167,10 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
// In ListView, always show the 0th query's name; otherwise use the active query's name
const displayedQueryName = isListView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const displayedQueryName =
isListView || isAIObservabilityRowView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const handleQueryChange = (value: number): void => {
setLastUsedQuery(value);
@@ -182,7 +183,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{queryOptions.length > 1 && (!isListView || shouldShowDropdownInListView) ? (
{queryOptions.length > 1 &&
!isAIObservabilityRowView &&
(!isListView || shouldShowDropdownInListView) ? (
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
placeholder="Select a query"
@@ -318,6 +321,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<Duration
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>

View File

@@ -1,12 +1,14 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig } from 'api/querySuggestions/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
FieldDataType,
TelemetryFieldKey,
@@ -41,23 +43,31 @@ function OtherFilters({
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
const fieldKeysConfig: FieldKeysConfig = isAIObservability
? { searchText: inputValue }
: {
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
};
const { data: fetchedKeys, isFetching } = useFieldKeysSuggestion(
fieldKeysConfig,
builderQueryType,
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
const suggestions: TelemetryFieldKey[] = (fetchedKeys ?? []).map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
@@ -71,7 +81,7 @@ function OtherFilters({
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
}, [fetchedKeys, addedFilters]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);

View File

@@ -0,0 +1,81 @@
import { screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render } from 'tests/test-utils';
import { SignalType } from '../../types';
import OtherFilters from '../OtherFilters';
const BASE_URL = ENVIRONMENT.baseURL;
const FIELDS_KEYS_URL = `${BASE_URL}/api/v1/fields/keys`;
const AI_KEYS_URL = `${BASE_URL}/api/v1/ai_observability/fields/keys`;
function keysResponse(name: string): Record<string, unknown> {
return {
status: 'success',
data: {
complete: true,
keys: {
[name]: [{ name, fieldContext: 'attribute', fieldDataType: 'string' }],
},
},
};
}
describe('OtherFilters - AI observability keys', () => {
let fieldsKeysCalled: boolean;
let aiKeysParams: URLSearchParams | undefined;
beforeEach(() => {
fieldsKeysCalled = false;
aiKeysParams = undefined;
server.use(
rest.get(FIELDS_KEYS_URL, (_, res, ctx) => {
fieldsKeysCalled = true;
return res(ctx.status(200), ctx.json(keysResponse('http.route')));
}),
rest.get(AI_KEYS_URL, (req, res, ctx) => {
aiKeysParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(keysResponse('gen_ai.request.model')));
}),
);
});
function renderOtherFilters(signal: SignalType): void {
render(
<OtherFilters
signal={signal}
inputValue=""
addedFilters={[]}
setAddedFilters={jest.fn()}
/>,
);
}
it('reads AI observability keys from their own endpoint', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
await expect(
screen.findByText('gen_ai.request.model'),
).resolves.toBeInTheDocument();
expect(fieldsKeysCalled).toBe(false);
});
it('does not narrow the AI keys by fieldContext', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
// A `trace` context would return only the computed per-trace aggregates,
// which cannot be filtered on.
await waitFor(() => expect(aiKeysParams).toBeDefined());
expect(aiKeysParams?.get('fieldContext')).toBeNull();
});
it('keeps other signals on the signal-wide keys endpoint', async () => {
renderOtherFilters(SignalType.TRACES);
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiKeysParams).toBeUndefined());
});
});

View File

@@ -7,4 +7,5 @@ export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
[SignalType.METER_EXPLORER]: DataSource.METRICS,
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
};

View File

@@ -0,0 +1,81 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { QuickFiltersSource } from '../../types';
import useActiveQueryIndex from '../useActiveQueryIndex';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const LAST_USED_QUERY = 2;
function mockQueryBuilder(panelType: PANEL_TYPES): void {
(useQueryBuilder as jest.Mock).mockReturnValue({
lastUsedQuery: LAST_USED_QUERY,
panelType,
});
}
describe('useActiveQueryIndex', () => {
describe('AI observability builds a single query in the row-level views', () => {
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'drives the first query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(0);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'follows the last used query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(LAST_USED_QUERY);
},
);
});
describe('other sources are unchanged', () => {
it('lets the traces explorer track the last used query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.TRACES_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
it('pins single-query sources to the first query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.INFRA_MONITORING),
);
expect(result.current).toBe(0);
});
it('tracks the last used query outside list view', () => {
mockQueryBuilder(PANEL_TYPES.TIME_SERIES);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.LOGS_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
});
});

View File

@@ -15,13 +15,21 @@ function useActiveQueryIndex(source: QuickFiltersSource): number {
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
// AI observability builds a single query in the row-level views, so its
// filters always drive the first one there.
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
return isListView || panelType === PANEL_TYPES.TRACE
? 0
: lastUsedQuery || 0;
}
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}, [isListView, panelType, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -24,6 +24,7 @@ export enum SignalType {
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai_observability',
}
/**
@@ -69,6 +70,7 @@ export enum QuickFiltersSource {
API_MONITORING = 'api-monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai-observability',
}
/**

View File

@@ -32,7 +32,6 @@ import {
MeterAggregateOperator,
MetricAggregateOperator,
NumberOperators,
QueryAdditionalFilter,
QueryBuilderData,
ReduceOperators,
StringOperators,
@@ -104,43 +103,6 @@ export const metricsSpaceAggregationOperatorsByType = {
ExponentialHistogram: metricsHistogramSpaceAggregateOperatorOptions,
};
export const mapOfQueryFilters: Record<DataSource, QueryAdditionalFilter[]> = {
metrics: [
{ text: 'Aggregation interval', field: 'stepInterval' },
{ text: 'Having', field: 'having' },
],
logs: [
{ text: 'Order by', field: 'orderBy' },
{ text: 'Limit', field: 'limit' },
{ text: 'Having', field: 'having' },
{ text: 'Aggregation interval', field: 'stepInterval' },
],
traces: [
{ text: 'Order by', field: 'orderBy' },
{ text: 'Limit', field: 'limit' },
{ text: 'Having', field: 'having' },
{ text: 'Aggregation interval', field: 'stepInterval' },
],
};
const commonFormulaFilters: QueryAdditionalFilter[] = [
{
text: 'Having',
field: 'having',
},
{ text: 'Order by', field: 'orderBy' },
{ text: 'Limit', field: 'limit' },
];
export const mapOfFormulaToFilters: Record<
DataSource,
QueryAdditionalFilter[]
> = {
metrics: commonFormulaFilters,
logs: commonFormulaFilters,
traces: commonFormulaFilters,
};
export const REDUCE_TO_VALUES: SelectOption<ReduceOperators, string>[] = [
{ value: ReduceOperators.LAST, label: 'Latest of values in timeframe' },
{ value: ReduceOperators.SUM, label: 'Sum of values in timeframe' },

View File

@@ -109,6 +109,9 @@ export const REACT_QUERY_KEY = {
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// Field Values Suggestion Query Keys
FIELD_VALUES_SUGGESTION: 'FIELD_VALUES_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
} as const;

View File

@@ -8,18 +8,13 @@ import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -28,7 +23,6 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -37,7 +31,7 @@ import {
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from 'pages/TracesExplorer/aiActions';
} from './aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -45,12 +39,10 @@ import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
@@ -60,7 +52,6 @@ import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
@@ -72,20 +63,12 @@ function Explorer(): JSX.Element {
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -112,19 +95,24 @@ function Explorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const { startUnixMilli, endUnixMilli } = useSignalFieldApis();
// existingQuery is left unset so related values auto-extract from the current query
const quickFiltersFieldApis = useMemo(
() => ({ startUnixMilli, endUnixMilli }),
[startUnixMilli, endUnixMilli],
);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
PANEL_TYPES.LIST,
DEFAULT_PANEL_TYPE,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -139,7 +127,7 @@ function Explorer(): JSX.Element {
},
[handleExplorerTabChange, handleSetConfig],
);
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
@@ -179,59 +167,6 @@ function Explorer(): JSX.Element {
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
@@ -260,8 +195,9 @@ function Explorer(): JSX.Element {
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
@@ -354,14 +290,6 @@ function Explorer(): JSX.Element {
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</Sentry.ErrorBoundary>

View File

@@ -12,25 +12,17 @@ import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -42,6 +34,7 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
@@ -79,14 +72,6 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -98,19 +83,6 @@ function ListView({
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -120,7 +92,6 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
@@ -128,7 +99,6 @@ function ListView({
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
@@ -150,7 +120,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
selectColumns: defaultSelectedColumns,
},
},
ENTITY_VERSION_V5,
@@ -158,10 +128,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);
@@ -186,28 +153,20 @@ function ListView({
[queryTableDataResult],
);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
[],
);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
@@ -235,15 +194,9 @@ function ListView({
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
@@ -251,6 +204,8 @@ function ListView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -258,8 +213,6 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);

View File

@@ -1,19 +1,41 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;
export const defaultSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'name',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
},
{
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
signal: 'traces',
fieldContext: 'span',
},
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,47 +1,8 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
@@ -60,95 +21,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.

View File

@@ -1,35 +1,25 @@
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
const isListViewPanel = useMemo(
const isRawQuery = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={isListViewPanel}
showOnlyWhereClause={isRawQuery}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -107,7 +107,7 @@ function TableView({
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
fileName="ai-traces-table"
/>
</div>
)}

View File

@@ -126,6 +126,7 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
exportFileName="ai-traces-timeseries"
/>
</div>
);

View File

@@ -55,6 +55,9 @@ function TracesTable({
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
// Rows can land before the field keys, and mounting then renders a partial column set.
const canMountTable = !isError && !isLoading && data.length !== 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
@@ -83,7 +86,7 @@ function TracesTable({
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
{canMountTable && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { buildTraceViewColumns } from '../../TracesView/configs';
import TracesTable from '../TracesTable';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);
return (
<>
<button type="button" onClick={(): void => setColumnsReady(true)}>
columns-ready
</button>
<TracesTable
data={ROWS}
columns={columnsReady ? COLUMNS : []}
columnStorageKey={STORAGE_KEY}
respectColumnOrder
panelType="TRACE"
getRowHref={(): string => '/trace/abc'}
isLoading={!columnsReady}
isFetching={false}
isError={false}
error={null}
isFilterApplied={false}
/>
</>
);
}
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
describe('TracesTable column-init race', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
it('does not persist empty defaults when rows land before columns', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RaceHarness />);
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -1,6 +1,13 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
export const TIMESTAMP_FIELD_NAMES = new Set([
'timestamp',
'start_time',
'end_time',
'last_activity_time',
]);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
@@ -13,6 +20,12 @@ export const STATUS_FIELD_NAMES = new Set([
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
export const DURATION_FIELD_NAMES = new Set([
'durationNano',
'duration_nano',
'trace_duration_nano',
'max_llm_duration_nano',
]);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -67,6 +67,7 @@ function TracesView({
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const {
@@ -168,9 +169,22 @@ function TracesView({
setOrderBy(value);
}, []);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
@@ -207,8 +221,12 @@ function TracesView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}

View File

@@ -0,0 +1,190 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
const OPTIONS_TRIGGER = 'options_menu.options';
const ROWS = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'checkout',
root_span_name: 'HTTP GET',
trace_duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
];
const mockRows = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'trace',
data: { results: [{ queryName: 'A', rows: ROWS }] },
},
}),
),
),
);
};
const mockFieldKeys = (names: string[]): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
),
),
);
};
const mockFieldKeysFailure = (): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView column persistence', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
mockRows();
});
afterEach(() => {
server.resetHandlers();
});
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('persists nothing when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
});
});

View File

@@ -149,6 +149,62 @@ describe('useTraceViewColumns', () => {
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();

View File

@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',

View File

@@ -35,11 +35,17 @@ interface UseTraceViewColumns {
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
@@ -60,10 +66,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
@@ -83,6 +89,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
@@ -96,7 +106,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns],
[columns, isSuccess],
);
return {
@@ -105,5 +115,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -1,7 +1,17 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
list: {
name: 'list',
label: 'List',
@@ -15,13 +25,6 @@ export const TOOLBAR_VIEWS = {
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',

View File

@@ -1,6 +1,5 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
@@ -31,31 +30,3 @@ export const getListViewQuery = (
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -1,19 +1,21 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
import './ToolbarActions.styles.scss';
interface ToolbarViewItem {
name: string;
key: string;
show?: boolean;
disabled?: boolean;
}
interface LeftToolbarActionsProps {
items: any;
items: Record<string, ToolbarViewItem>;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
@@ -29,8 +31,6 @@ export default function LeftToolbarActions({
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
@@ -41,91 +41,34 @@ export default function LeftToolbarActions({
</Button>
</Tooltip>
)}
{/* Buttons render in the order the caller declares its views. */}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{Object.values(items).map((item) => {
const config = TOOLBAR_VIEW_CONFIG[item?.key];
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
if (!item?.show || !config) {
return null;
}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
const { icon: Icon, label, className, testId } = config;
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
return (
<Tooltip key={item.key} title={label}>
<Button
disabled={item.disabled}
className={cx(
className,
'explorer-view-option',
selectedView === item.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
>
<Icon size={14} data-testid={testId} />
{label}
</Button>
</Tooltip>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,47 @@
import {
Atom,
Binoculars,
SquareMousePointer,
Terminal,
} from '@signozhq/icons';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
export interface ToolbarViewConfig {
icon: typeof Atom;
label: string;
className: string;
testId: string;
}
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
[ExplorerViews.LIST]: {
icon: SquareMousePointer,
label: 'List View',
className: 'list-view-tab',
testId: 'search-view',
},
[ExplorerViews.TRACE]: {
icon: SquareMousePointer,
label: 'Trace View',
className: 'trace-view-tab',
testId: 'trace-view',
},
[ExplorerViews.TIMESERIES]: {
icon: Atom,
label: 'Time Series',
className: 'timeseries-view-tab',
testId: 'query-builder-view',
},
[ExplorerViews.CLICKHOUSE]: {
icon: Terminal,
label: 'Clickhouse',
className: 'clickhouse-view-tab',
testId: 'clickhouse-view',
},
[ExplorerViews.TABLE]: {
icon: Binoculars,
label: 'Table',
className: 'table-view-tab',
testId: 'query-builder-view-v2',
},
};

View File

@@ -1,13 +1,6 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import {
initialQueriesMap,
OPERATORS,
PANEL_TYPES,
} from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -36,42 +29,11 @@ function LogExplorerQuerySection({
useShareBuilderUrl({ defaultValue });
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isTable = panelTypes === PANEL_TYPES.TABLE;
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: isTable, isDisabled: false },
having: { isHidden: isList, isDisabled: true },
filters: {
customKey: 'body',
customOp: OPERATORS.CONTAINS,
},
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps): JSX.Element => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo(
(): QueryBuilderProps['queryComponents'] => ({
...(panelTypes === PANEL_TYPES.LIST ? { renderOrderBy } : {}),
}),
[panelTypes, renderOrderBy],
);
return (
<QueryBuilderV2
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
isRawQuery={panelTypes === PANEL_TYPES.LIST}
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
panelType={panelTypes}
filterConfigs={filterConfigs}
queryComponents={queryComponents}
showOnlyWhereClause={selectedView === ExplorerViews.LIST}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>

View File

@@ -12,7 +12,6 @@ import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
@@ -120,11 +119,6 @@ function Explorer(): JSX.Element {
});
}, []);
const queryComponents = useMemo(
(): QueryBuilderProps['queryComponents'] => ({}),
[],
);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
@@ -181,7 +175,6 @@ function Explorer(): JSX.Element {
signalSource: 'meter',
}}
panelType={PANEL_TYPES.TIME_SERIES}
queryComponents={queryComponents}
showFunctions={false}
version="v3"
/>

View File

@@ -12,7 +12,6 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
@@ -323,11 +322,6 @@ function Explorer(): JSX.Element {
});
}, []);
const queryComponents = useMemo(
(): QueryBuilderProps['queryComponents'] => ({}),
[],
);
const [warning, setWarning] = useState<Warning | undefined>();
const oneChartPerQueryDisabledTooltip = useMemo(() => {
@@ -381,7 +375,6 @@ function Explorer(): JSX.Element {
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
queryComponents={queryComponents}
showFunctions={false}
version="v3"
/>

View File

@@ -1,22 +1,9 @@
import { ReactNode } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
type FilterConfigs = {
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
isHidden: boolean;
isDisabled: boolean;
};
} & { filters: WhereClauseConfig };
export type QueryBuilderConfig =
| {
queryVariant: 'static';
@@ -29,9 +16,16 @@ export type QueryBuilderProps = {
config?: QueryBuilderConfig;
panelType: PANEL_TYPES;
actions?: ReactNode;
filterConfigs?: Partial<FilterConfigs>;
queryComponents?: { renderOrderBy?: (props: OrderByFilterProps) => ReactNode };
isListViewPanel?: boolean;
fieldsConfig?: QueryBuilderFieldsConfig;
/**
* The builder edits raw rows rather than an aggregation: a single query unless trace
* matching is on, no formulas, data-source switches reset to the raw-query template,
* and order by resolves keys without an aggregate attribute. Supplies the defaults for
* `fieldsConfig` and `allowedDataSources`, which override it per field.
*/
isRawQuery?: boolean;
/** Defaults to every signal. */
allowedDataSources?: TelemetrytypesSignalDTO[];
showFunctions?: boolean;
showOnlyWhereClause?: boolean;
showOnlyTraceOperator?: boolean;

View File

@@ -1,6 +0,0 @@
import { ReactNode } from 'react';
export type AdditionalFiltersProps = {
listOfAdditionalFilter: string[];
children: ReactNode;
};

View File

@@ -1,38 +0,0 @@
import { SquareMinus, SquarePlus } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Col } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled, { css } from 'styled-components';
const IconCss = css`
margin-right: 0.6875rem;
transition: all 0.2s ease;
`;
export const StyledIconOpen = styled(SquarePlus)`
${IconCss}
`;
export const StyledIconClose = styled(SquareMinus)`
${IconCss}
`;
export const StyledInner = styled(Col)`
width: fit-content;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 0.875rem;
min-height: 1.375rem;
cursor: pointer;
&:hover {
${StyledIconOpen}, ${StyledIconClose} {
opacity: 0.7;
}
}
`;
export const StyledLink = styled(Typography.Link)`
pointer-events: none;
color: ${Color.BG_ROBIN_400} !important;
`;

View File

@@ -1,15 +0,0 @@
.filter-toggler {
margin-right: 8px;
}
.additinal-filters-container {
.action-btn {
background: var(--primary-background);
width: 16px;
height: 16px;
border-radius: 3px;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@@ -1,66 +0,0 @@
import { Fragment, memo, ReactNode, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Col, Row } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Minus, Plus } from '@signozhq/icons';
// ** Types
import { AdditionalFiltersProps } from './AdditionalFiltersToggler.interfaces';
// ** Styles
import { StyledInner, StyledLink } from './AdditionalFiltersToggler.styled';
import './AdditionalFiltersToggler.styles.scss';
export const AdditionalFiltersToggler = memo(function AdditionalFiltersToggler({
children,
listOfAdditionalFilter,
}: AdditionalFiltersProps): JSX.Element {
const [isOpenedFilters, setIsOpenedFilters] = useState<boolean>(false);
const handleToggleOpenFilters = (): void => {
setIsOpenedFilters((prevState) => !prevState);
};
const filtersTexts: ReactNode = listOfAdditionalFilter?.map((str, index) => {
const isNextLast = index + 1 === listOfAdditionalFilter.length - 1;
if (index === listOfAdditionalFilter.length - 1) {
return (
<Fragment key={str}>
{listOfAdditionalFilter?.length > 1 && 'and'}{' '}
<StyledLink>{str.toUpperCase()}</StyledLink>
</Fragment>
);
}
return (
<span key={str}>
<StyledLink>{str.toUpperCase()}</StyledLink>
{isNextLast ? ' ' : ', '}
</span>
);
});
return (
<Row className="additinal-filters-container">
<Col span={24}>
<StyledInner onClick={handleToggleOpenFilters} style={{ marginBottom: 0 }}>
{isOpenedFilters ? (
<span className="action-btn">
<Minus size={14} color={Color.BG_INK_500} />
</span>
) : (
<span className="action-btn">
<Plus size={14} color={Color.BG_INK_500} />
</span>
)}
{!isOpenedFilters && (
<Typography>Add conditions for {filtersTexts}</Typography>
)}
</StyledInner>
</Col>
{isOpenedFilters && <Col span={24}>{children}</Col>}
</Row>
);
});

View File

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

View File

@@ -1,8 +1,10 @@
import { SelectProps } from 'antd';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
export type QueryLabelProps = {
onChange: (value: DataSource) => void;
isListViewPanel?: boolean;
/** Defaults to every signal. */
allowedDataSources?: TelemetrytypesSignalDTO[];
'data-testid'?: string;
} & Omit<SelectProps, 'onChange'>;

View File

@@ -1,5 +1,6 @@
import { memo } from 'react';
import { Select } from 'antd';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
// ** Helpers
@@ -7,25 +8,24 @@ import { transformToUpperCase } from 'utils/transformToUpperCase';
// ** Types
import { QueryLabelProps } from './DataSourceDropdown.interfaces';
import { signalsToDataSources } from './DataSourceDropdown.utils';
const dataSourceMap = [DataSource.LOGS, DataSource.METRICS, DataSource.TRACES];
const exploreDataSourceMap = [DataSource.LOGS, DataSource.TRACES];
const ALL_SIGNALS = [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.traces,
];
export const DataSourceDropdown = memo(function DataSourceDropdown(
props: QueryLabelProps,
): JSX.Element {
const { onChange, value, style, isListViewPanel = false } = props;
const { onChange, value, style, allowedDataSources = ALL_SIGNALS } = props;
const dataSourceOptions: SelectOption<DataSource, string>[] = isListViewPanel
? exploreDataSourceMap.map((source) => ({
label: transformToUpperCase(source),
value: source,
}))
: dataSourceMap.map((source) => ({
label: transformToUpperCase(source),
value: source,
}));
const dataSourceOptions: SelectOption<DataSource, string>[] =
signalsToDataSources(allowedDataSources).map((source) => ({
label: transformToUpperCase(source),
value: source,
}));
return (
<Select

View File

@@ -0,0 +1,23 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
// Total, not Partial, so a signal added to the generated enum has to be mapped here
// before it compiles.
const SIGNAL_TO_DATA_SOURCE: Record<
TelemetrytypesSignalDTO,
DataSource | undefined
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
// The "unset" member: not a data source a query can be built against.
[TelemetrytypesSignalDTO['']]: undefined,
};
export function signalsToDataSources(
signals: readonly TelemetrytypesSignalDTO[],
): DataSource[] {
return signals
.map((signal) => SIGNAL_TO_DATA_SOURCE[signal])
.filter((dataSource): dataSource is DataSource => Boolean(dataSource));
}

View File

@@ -1,6 +0,0 @@
import { CSSProperties } from 'react';
export type FilterLabelProps = {
label: string;
style?: CSSProperties;
};

View File

@@ -1,16 +0,0 @@
import styled from 'styled-components';
interface Props {
isDarkMode: boolean;
children?: React.ReactNode;
}
export const StyledLabel = styled.div<Props>`
padding: 0 0.6875rem;
min-height: 2rem;
min-width: 5.625rem;
display: inline-flex;
white-space: nowrap;
align-items: center;
border-radius: 0.125rem;
`;

View File

@@ -1,26 +0,0 @@
import { memo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
// ** Types
import { FilterLabelProps } from './FilterLabel.interfaces';
// ** Styles
import { StyledLabel } from './FilterLabel.styled';
export const FilterLabel = memo(function FilterLabel({
label,
}: FilterLabelProps): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<StyledLabel isDarkMode={isDarkMode}>
<Typography
style={{
color: 'var(--bg-vanilla-400)',
}}
>
{label}
</Typography>
</StyledLabel>
);
});

View File

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

View File

@@ -1,4 +1,3 @@
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import {
IBuilderFormula,
IBuilderQuery,
@@ -8,7 +7,5 @@ export type FormulaProps = {
formula: IBuilderFormula;
index: number;
query: IBuilderQuery;
filterConfigs: Partial<QueryBuilderProps['filterConfigs']>;
isAdditionalFilterEnable: boolean;
isQBV2?: boolean;
};

View File

@@ -2,11 +2,6 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { LEGEND } from 'constants/global';
// ** Components
import { FilterLabel } from 'container/QueryBuilder/components';
import HavingFilter from 'container/QueryBuilder/filters/Formula/Having/HavingFilter';
import LimitFilter from 'container/QueryBuilder/filters/Formula/Limit/Limit';
import OrderByFilter from 'container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter';
// ** Hooks
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -17,7 +12,6 @@ import {
import { getFormatedLegend } from 'utils/getFormatedLegend';
import { popupContainer } from 'utils/selectPopupContainer';
import { AdditionalFiltersToggler } from '../AdditionalFiltersToggler';
import QBEntityOptions from '../QBEntityOptions/QBEntityOptions';
// ** Types
import { FormulaProps } from './Formula.interfaces';
@@ -27,22 +21,18 @@ import './Formula.styles.scss';
export function Formula({
index,
formula,
filterConfigs,
query,
isAdditionalFilterEnable,
isQBV2,
}: FormulaProps): JSX.Element {
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
useQueryBuilder();
const { listOfAdditionalFormulaFilters, handleChangeFormulaData } =
useQueryOperations({
index,
query,
filterConfigs,
formula,
entityVersion: '',
});
const { handleChangeFormulaData } = useQueryOperations({
index,
query,
formula,
entityVersion: '',
});
const [isCollapse, setIsCollapsed] = useState(false);
@@ -83,20 +73,6 @@ export function Formula({
[handleChangeFormulaData],
);
const handleChangeHavingFilter = useCallback(
(value: IBuilderFormula['having']) => {
handleChangeFormulaData('having', value);
},
[handleChangeFormulaData],
);
const handleChangeOrderByFilter = useCallback(
(value: IBuilderFormula['orderBy']) => {
handleChangeFormulaData('orderBy', value);
},
[handleChangeFormulaData],
);
const handleQBV2OrderByChange = useCallback(
(value: string) => {
const [columnName, order] = value.split(' ');
@@ -122,54 +98,6 @@ export function Formula({
[formula.orderBy],
);
const renderAdditionalFilters = useMemo(
() => (
<>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Limit" />
</Col>
<Col flex="1 1 12.5rem">
<LimitFilter formula={formula} onChange={handleChangeLimit} />
</Col>
</Row>
</Col>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="HAVING" />
</Col>
<Col flex="1 1 12.5rem">
<HavingFilter formula={formula} onChange={handleChangeHavingFilter} />
</Col>
</Row>
</Col>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Order by" />
</Col>
<Col flex="1 1 12.5rem">
<OrderByFilter
query={query}
formula={formula}
onChange={handleChangeOrderByFilter}
/>
</Col>
</Row>
</Col>
</>
),
[
formula,
handleChangeHavingFilter,
handleChangeLimit,
handleChangeOrderByFilter,
query,
],
);
return (
<Row gutter={[0, 15]}>
<QBEntityOptions
@@ -206,17 +134,6 @@ export function Formula({
addonBefore="Legend Format"
/>
</Col>
{isAdditionalFilterEnable && (
<Col span={24}>
<AdditionalFiltersToggler
listOfAdditionalFilter={listOfAdditionalFormulaFilters}
>
<Row gutter={[0, 11]} justify="space-between">
{renderAdditionalFilters}
</Row>
</AdditionalFiltersToggler>
</Col>
)}
{isQBV2 && (
<Col span={24}>
<div className="formula-qbv2-container">

View File

@@ -84,5 +84,14 @@
.options-group {
max-width: 100%;
}
.query-functions-container--disabled {
opacity: 0.45;
cursor: not-allowed;
> * {
pointer-events: none;
}
}
}
}

View File

@@ -1,5 +1,6 @@
import { useLocation } from 'react-router-dom';
import { Button, Col, Tooltip } from 'antd';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -26,6 +27,8 @@ interface QBEntityOptionsProps {
query?: IBuilderQuery;
isMetricsDataSource?: boolean;
showFunctions?: boolean;
areFunctionsDisabled?: boolean;
functionsDisabledReason?: string;
isCollapsed: boolean;
entityType: string;
entityData: any;
@@ -36,7 +39,8 @@ interface QBEntityOptionsProps {
onQueryFunctionsUpdates?: (functions: QueryFunction[]) => void;
showDeleteButton?: boolean;
showCloneOption?: boolean;
isListViewPanel?: boolean;
isRawQuery?: boolean;
allowedDataSources?: TelemetrytypesSignalDTO[];
index?: number;
showTraceOperator?: boolean;
hasTraceOperator?: boolean;
@@ -50,12 +54,15 @@ export default function QBEntityOptions({
isMetricsDataSource,
isCollapsed,
showFunctions,
areFunctionsDisabled,
functionsDisabledReason,
entityType,
entityData,
onToggleVisibility,
onCollapseEntity,
onQueryFunctionsUpdates,
isListViewPanel,
isRawQuery,
allowedDataSources,
onDelete,
showDeleteButton,
showCloneOption,
@@ -100,7 +107,7 @@ export default function QBEntityOptions({
value="query-builder"
className="periscope-btn visibility-toggle"
onClick={onToggleVisibility}
disabled={isListViewPanel && !showTraceOperator}
disabled={isRawQuery && !showTraceOperator}
>
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
@@ -119,7 +126,7 @@ export default function QBEntityOptions({
'periscope-btn',
entityType === 'query' ? 'query-name' : 'formula-name',
query?.dataSource === DataSource.TRACES &&
(hasTraceOperator || (showTraceOperator && isListViewPanel))
(hasTraceOperator || (showTraceOperator && isRawQuery))
? 'has-trace-operator'
: '',
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
@@ -138,24 +145,33 @@ export default function QBEntityOptions({
}}
data-testid={`query-data-source-selector-${index}`}
value={query?.dataSource || DataSource.METRICS}
isListViewPanel={isListViewPanel}
allowedDataSources={allowedDataSources}
className="query-data-source-dropdown"
/>
</div>
)}
{showFunctions &&
!isListViewPanel &&
!isRawQuery &&
(isMetricsDataSource || isLogsDataSource) &&
query &&
onQueryFunctionsUpdates && (
<QueryFunctions
query={query}
queryFunctions={query.functions || []}
key={query.functions?.toString()}
onChange={onQueryFunctionsUpdates}
maxFunctions={isLogsDataSource ? 1 : 3}
/>
<Tooltip title={functionsDisabledReason}>
<div
className={cx('query-functions-container', {
'query-functions-container--disabled': areFunctionsDisabled,
})}
aria-disabled={areFunctionsDisabled}
>
<QueryFunctions
query={query}
queryFunctions={query.functions || []}
key={query.functions?.toString()}
onChange={onQueryFunctionsUpdates}
maxFunctions={isLogsDataSource ? 1 : 3}
/>
</div>
</Tooltip>
)}
</Button.Group>
</div>
@@ -168,7 +184,7 @@ export default function QBEntityOptions({
)}
</div>
{showDeleteButton && !isListViewPanel && (
{showDeleteButton && !isRawQuery && (
<Button className="periscope-btn ghost" onClick={onDelete}>
<Trash2 size={14} />
</Button>
@@ -179,11 +195,14 @@ export default function QBEntityOptions({
}
QBEntityOptions.defaultProps = {
isListViewPanel: false,
isRawQuery: false,
allowedDataSources: undefined,
query: undefined,
isMetricsDataSource: false,
onQueryFunctionsUpdates: undefined,
showFunctions: false,
areFunctionsDisabled: false,
functionsDisabledReason: undefined,
onCloneQuery: noop,
index: 0,
onDelete: noop,

View File

@@ -1,6 +1,4 @@
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
export { DataSourceDropdown } from './DataSourceDropdown';
export { FilterLabel } from './FilterLabel';
export { Formula } from './Formula';
export { HavingFilterTag } from './HavingFilterTag';
export { ListItemWrapper } from './ListItemWrapper';

View File

@@ -1,198 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { HAVING_OPERATORS, initialHavingValues } from 'constants/queryBuilder';
import { HavingFilterTag } from 'container/QueryBuilder/components';
import { useTagValidation } from 'hooks/queryBuilder/useTagValidation';
import {
transformFromStringToHaving,
transformHavingToStringValue,
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
const [currentFormValue, setCurrentFormValue] =
useState<HavingForm>(initialHavingValues);
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
const { isMulti } = useTagValidation(
currentFormValue.op,
currentFormValue.value,
);
const columnName = formula.expression.replace(/ /g, '').toUpperCase();
const aggregatorOptions: SelectOption<string, string>[] = useMemo(
() => [{ label: columnName, value: columnName }],
[columnName],
);
const handleUpdateTag = useCallback(
(value: string) => {
const filteredValues = localValues.filter(
(currentValue) => currentValue !== value,
);
const having: Having[] = filteredValues.map(transformFromStringToHaving);
onChange(having);
setSearchText(value);
},
[localValues, onChange],
);
const generateOptions = useCallback(
(currentString: string) => {
const [aggregator = '', op = '', ...restValue] = currentString.split(' ');
let newOptions: SelectOption<string, string>[] = [];
const isAggregatorExist = columnName
.toLowerCase()
.includes(currentString.toLowerCase());
const isAggregatorChosen = aggregator === columnName;
if (isAggregatorExist || aggregator === '') {
newOptions = aggregatorOptions;
}
if ((isAggregatorChosen && op === '') || op) {
const filteredOperators = HAVING_OPERATORS.filter((num) =>
num.toLowerCase().includes(op.toLowerCase()),
);
newOptions = filteredOperators.map((opt) => ({
label: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
value: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
}));
}
setOptions(newOptions);
},
[aggregatorOptions, columnName],
);
const parseSearchText = useCallback(
(text: string) => {
const { columnName, op, value } = getHavingObject(text);
setCurrentFormValue({ columnName, op, value });
generateOptions(text);
},
[generateOptions],
);
const tagRender = ({
label,
value,
closable,
disabled,
onClose,
}: HavingTagRenderProps): JSX.Element => {
const handleClose = (): void => {
onClose();
setSearchText('');
};
return (
<HavingFilterTag
label={label}
value={value}
closable={closable}
disabled={disabled}
onClose={handleClose}
onUpdate={handleUpdateTag}
/>
);
};
const handleSearch = (search: string): void => {
const trimmedSearch = search.replace(/\s\s+/g, ' ').trimStart();
const currentSearch = isMulti
? trimmedSearch
: trimmedSearch.split(' ').slice(0, 3).join(' ');
const isValidSearch = isValidHavingValue(currentSearch);
if (isValidSearch) {
setSearchText(currentSearch);
}
};
useEffect(() => {
setLocalValues(transformHavingToStringValue(having || []));
}, [having]);
useEffect(() => {
parseSearchText(searchText);
}, [searchText, parseSearchText]);
const resetChanges = (): void => {
setSearchText('');
setCurrentFormValue(initialHavingValues);
setOptions(aggregatorOptions);
};
const handleDeselect = (value: string): void => {
const result = localValues.filter((item) => item !== value);
const having: Having[] = result.map(transformFromStringToHaving);
onChange(having);
resetChanges();
};
const handleSelect = (currentValue: string): void => {
const { columnName, op, value } = getHavingObject(currentValue);
const isCompletedValue = value.every((item) => !!item);
const isClearSearch = isCompletedValue && columnName && op;
setSearchText(isClearSearch ? '' : currentValue);
};
const handleChange = (values: string[]): void => {
const having: Having[] = values.map(transformFromStringToHaving);
const isSelectable =
currentFormValue.value.length > 0 &&
currentFormValue.value.every((value) => !!value);
if (isSelectable) {
onChange(having);
resetChanges();
}
};
return (
<Select
getPopupContainer={getPopupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}
searchValue={searchText}
data-testid="havingSelectFormula"
placeholder="Count(operation) > 5"
style={{ width: '100%' }}
tagRender={tagRender}
onDeselect={handleDeselect}
onSelect={handleSelect}
onChange={handleChange}
value={localValues}
>
{options.map((opt) => (
<Select.Option key={opt.value} value={opt.value} title="havingOption">
{opt.label}
</Select.Option>
))}
</Select>
);
}
export default HavingFilter;

View File

@@ -1,12 +0,0 @@
import { HavingFilterTagProps } from 'container/QueryBuilder/components/HavingFilterTag/HavingFilterTag.interfaces';
import {
Having,
IBuilderFormula,
} from 'types/api/queryBuilder/queryBuilderData';
export type HavingFilterProps = {
formula: IBuilderFormula;
onChange: (having: Having[]) => void;
};
export type HavingTagRenderProps = Omit<HavingFilterTagProps, 'onUpdate'>;

View File

@@ -1,20 +0,0 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';
function LimitFilter({ onChange, formula }: LimitFilterProps): JSX.Element {
return (
<InputNumber
min={1}
type="number"
value={formula.limit}
style={selectStyle}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
export default LimitFilter;

View File

@@ -1,6 +0,0 @@
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
export interface LimitFilterProps {
onChange: (values: number | null) => void;
formula: IBuilderFormula;
}

View File

@@ -1,85 +0,0 @@
import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';
function OrderByFilter({
formula,
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
aggregationOptions,
handleChange,
handleSearchKeys,
selectedValue,
generateOptions,
} = useOrderByFormulaFilter({
query,
onChange,
formula,
});
const { data, isFetching } = useGetAggregateKeys(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedSearchText,
},
{
enabled: !!query.aggregateAttribute?.key,
keepPreviousData: true,
},
);
const optionsData = useMemo(() => {
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
const groupByOptions = createOptions(query.groupBy);
const options =
query.aggregateOperator === MetricAggregateOperator.NOOP
? keyOptions
: [...groupByOptions, ...aggregationOptions];
return generateOptions(options);
}, [
aggregationOptions,
createOptions,
data?.payload?.attributeKeys,
generateOptions,
query.aggregateOperator,
query.groupBy,
]);
const isDisabledSelect =
!query.aggregateAttribute?.key ||
query.aggregateOperator === MetricAggregateOperator.NOOP;
return (
<Select
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}
showSearch
disabled={isDisabledSelect}
showArrow={false}
value={selectedValue}
labelInValue
filterOption={false}
options={optionsData}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
/>
);
}
export default OrderByFilter;

View File

@@ -1,12 +0,0 @@
import {
IBuilderFormula,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
export interface OrderByProps {
formula: IBuilderFormula;
query: IBuilderQuery;
onChange: (value: IBuilderFormula['orderBy']) => void;
}
export type IOrderByFormulaFilterProps = OrderByProps;

View File

@@ -1,129 +0,0 @@
import { useMemo, useState } from 'react';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import { IOption } from 'hooks/useResourceAttribute/types';
import isEqual from 'lodash-es/isEqual';
import uniqWith from 'lodash-es/uniqWith';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { ORDERBY_FILTERS } from '../../OrderByFilter/config';
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
import { UseOrderByFilterResult } from '../../OrderByFilter/useOrderByFilter';
import {
getLabelFromValue,
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';
export const useOrderByFormulaFilter = ({
onChange,
formula,
}: IOrderByFormulaFilterProps): UseOrderByFilterResult => {
const [searchText, setSearchText] = useState<string>('');
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
const handleSearchKeys = (searchText: string): void =>
setSearchText(searchText);
const handleChange = (values: IOption[]): void => {
const validResult = getValidOrderByResult(values);
const result = getUniqueOrderByValues(validResult);
const orderByValues: OrderByPayload[] = result.map((item) => {
const match = parse(item.value, { delimiter: orderByValueDelimiter });
if (!match) {
return {
columnName: item.value,
order: ORDERBY_FILTERS.ASC,
};
}
const [columnName, order] = match.data.flat() as string[];
const columnNameValue =
columnName === SIGNOZ_VALUE ? SIGNOZ_VALUE : columnName;
const orderValue = order ?? ORDERBY_FILTERS.ASC;
return {
columnName: columnNameValue,
order: orderValue,
};
});
setSearchText('');
onChange(orderByValues);
};
const aggregationOptions = [
{
label: `${formula.expression} ${ORDERBY_FILTERS.ASC}`,
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
},
{
label: `${formula.expression} ${ORDERBY_FILTERS.DESC}`,
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
},
];
const selectedValue = transformToOrderByStringValuesByFormula(formula);
const createOptions = (data: BaseAutocompleteData[]): IOption[] =>
mapLabelValuePairs(data).flat();
const customValue: IOption[] = useMemo(() => {
if (!searchText) {
return [];
}
return [
{
label: `${searchText} ${ORDERBY_FILTERS.ASC}`,
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
},
{
label: `${searchText} ${ORDERBY_FILTERS.DESC}`,
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
},
];
}, [searchText]);
const generateOptions = (options: IOption[]): IOption[] => {
const currentCustomValue = options.find(
(keyOption) =>
getRemoveOrderFromValue(keyOption.value) === debouncedSearchText,
)
? []
: customValue;
const result = [...currentCustomValue, ...options];
const uniqResult = uniqWith(result, isEqual);
return uniqResult.filter(
(option) =>
!getLabelFromValue(selectedValue).includes(
getRemoveOrderFromValue(option.value),
),
);
};
return {
searchText,
debouncedSearchText,
selectedValue,
aggregationOptions,
createOptions,
handleChange,
handleSearchKeys,
generateOptions,
};
};

View File

@@ -1,26 +0,0 @@
import { IOption } from 'hooks/useResourceAttribute/types';
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
import { orderByValueDelimiter } from '../../OrderByFilter/utils';
export const transformToOrderByStringValuesByFormula = (
formula: IBuilderFormula,
): IOption[] => {
const prepareSelectedValue: IOption[] =
formula?.orderBy?.map((item) => {
if (item.columnName === SIGNOZ_VALUE) {
return {
label: `${formula.expression} ${item.order}`,
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
};
}
return {
label: `${item.columnName} ${item.order}`,
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
};
}) || [];
return prepareSelectedValue;
};

View File

@@ -6,7 +6,7 @@ import {
export type OrderByFilterProps = {
query: IBuilderQuery;
onChange: (values: OrderByPayload[]) => void;
isListViewPanel?: boolean;
isRawQuery?: boolean;
entityVersion?: string;
isNewQueryV2?: boolean;
};

View File

@@ -12,7 +12,7 @@ import { useOrderByFilter } from './useOrderByFilter';
export function OrderByFilter({
query,
onChange,
isListViewPanel = false,
isRawQuery = false,
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
@@ -35,7 +35,7 @@ export function OrderByFilter({
searchText: debouncedSearchText,
},
{
enabled: !!query.aggregateAttribute?.key || isListViewPanel,
enabled: !!query.aggregateAttribute?.key || isRawQuery,
keepPreviousData: true,
},
);

View File

@@ -19,7 +19,6 @@ import {
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
@@ -88,7 +87,6 @@ interface CustomTagProps {
interface QueryBuilderSearchV2Props {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
@@ -145,7 +143,6 @@ function QueryBuilderSearchV2(
placeholder,
className,
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
hasPopupContainer,
rootClassName,
@@ -477,31 +474,7 @@ function QueryBuilderSearchV2(
if (searchValue) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
// if key is added and operator is not present then convert to body CONTAINS key
if (
currentFilterItem?.key &&
isEmpty(currentFilterItem?.op) &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig?.customOp === OPERATORS.CONTAINS
) {
// eslint-disable-next-line sonarjs/no-identical-functions
setTags((prev) => [
...prev,
{
key: {
key: 'body',
dataType: DataTypes.String,
type: '',
id: 'body--string----true',
},
op: OPERATORS.CONTAINS,
value: currentFilterItem?.key?.key,
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
currentFilterItem?.op === OPERATORS.EXISTS ||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
) {
@@ -543,8 +516,6 @@ function QueryBuilderSearchV2(
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
@@ -1085,7 +1056,6 @@ QueryBuilderSearchV2.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
hasPopupContainer: true,
rootClassName: '',
hardcodedAttributeKeys: undefined,

View File

@@ -26,7 +26,7 @@ export type QueryProps = {
isAvailableToDisable: boolean;
query: IBuilderQuery;
queryVariant?: 'static' | 'dropdown';
isListViewPanel?: boolean;
isRawQuery?: boolean;
showFunctions?: boolean;
version: string;
showSpanScopeSelector?: boolean;
@@ -35,4 +35,4 @@ export type QueryProps = {
hasTraceOperator?: boolean;
signalSource?: string;
isMultiQueryAllowed?: boolean;
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;
} & Pick<QueryBuilderProps, 'fieldsConfig' | 'allowedDataSources'>;

View File

@@ -64,6 +64,7 @@ function TimeSeriesView({
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
exportFileName,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -270,7 +271,7 @@ function TimeSeriesView({
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={`${dataSource}-timeseries`}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
@@ -339,6 +340,7 @@ interface TimeSeriesViewProps {
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
exportFileName?: string;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
@@ -351,6 +353,7 @@ TimeSeriesView.defaultProps = {
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
exportFileName: undefined,
};
export default TimeSeriesView;

View File

@@ -1,55 +1,23 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
const isRawQuery = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
isRawQuery={isRawQuery}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}

View File

@@ -316,7 +316,7 @@ function FullView({
<QueryBuilderV2
panelType={selectedPanelType}
version="v3"
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
isRawQuery={selectedPanelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
// filterConfigs={filterConfigs}
// queryComponents={queryComponents}

View File

@@ -14,8 +14,6 @@ import {
initialQueryBuilderFormValuesMap,
listViewInitialLogQuery,
listViewInitialTraceQuery,
mapOfFormulaToFilters,
mapOfQueryFilters,
PANEL_TYPES,
} from 'constants/queryBuilder';
import {
@@ -59,9 +57,8 @@ import { getFormatedLegend } from 'utils/getFormatedLegend';
export const useQueryOperations: UseQueryOperations = ({
query,
index,
filterConfigs,
formula,
isListViewPanel = false,
isRawQuery = false,
entityVersion,
isForTraceOperator = false,
savePreviousQuery = false,
@@ -105,46 +102,7 @@ export const useQueryOperations: UseQueryOperations = ({
}
}, [query]);
const { dataSource, aggregateOperator } = query;
const getNewListOfAdditionalFilters = useCallback(
(dataSource: DataSource, isQuery: boolean): string[] => {
const additionalFiltersKeys: (keyof Pick<
IBuilderQuery,
'orderBy' | 'limit' | 'having' | 'stepInterval'
>)[] = ['having', 'limit', 'orderBy', 'stepInterval'];
const mapsOfFilters = isQuery ? mapOfQueryFilters : mapOfFormulaToFilters;
const result: string[] = mapsOfFilters[dataSource]?.reduce<string[]>(
(acc, item) => {
if (
filterConfigs &&
filterConfigs[item.field as (typeof additionalFiltersKeys)[number]]
?.isHidden
) {
return acc;
}
acc.push(item.text);
return acc;
},
[],
);
return result;
},
[filterConfigs],
);
const [listOfAdditionalFilters, setListOfAdditionalFilters] = useState<
string[]
>(getNewListOfAdditionalFilters(dataSource, true));
const [listOfAdditionalFormulaFilters, setListOfAdditionalFormulaFilters] =
useState<string[]>(getNewListOfAdditionalFilters(dataSource, false));
const { dataSource } = query;
const handleChangeOperator = useCallback(
(value: string): void => {
@@ -460,7 +418,7 @@ export const useQueryOperations: UseQueryOperations = ({
removeKeyFromPreviousQuery(newKey);
}
if (isListViewPanel) {
if (isRawQuery) {
let listPanelQuery: Query | null = null;
if (nextSource === DataSource.LOGS) {
@@ -506,7 +464,7 @@ export const useQueryOperations: UseQueryOperations = ({
handleSetQueryData(index, newQueryData);
},
[
isListViewPanel,
isRawQuery,
panelType,
query,
handleSetQueryData,
@@ -625,32 +583,18 @@ export const useQueryOperations: UseQueryOperations = ({
handleMetricAggregateAtributeTypes,
]);
useEffect(() => {
const additionalFilters = getNewListOfAdditionalFilters(dataSource, true);
setListOfAdditionalFilters(additionalFilters);
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
useEffect(() => {
const additionalFilters = getNewListOfAdditionalFilters(dataSource, false);
setListOfAdditionalFormulaFilters(additionalFilters);
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
return {
isTracePanelType,
isMetricsDataSource,
isLogsDataSource,
operators,
spaceAggregationOptions,
listOfAdditionalFilters,
handleChangeOperator,
handleSpaceAggregationChange,
handleChangeAggregatorAttribute,
handleChangeDataSource,
handleDeleteQuery,
handleChangeQueryData,
listOfAdditionalFormulaFilters,
handleChangeFormulaData,
handleQueryFunctionsUpdates,
};

View File

@@ -0,0 +1,61 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import {
RenderErrorResponseDTO,
TelemetrytypesTelemetryFieldValuesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
FieldValuesConfig,
FieldValuesResponse,
} from 'api/querySuggestions/types';
import { BuilderQueryType } from 'types/api/v5/queryRange';
export type FieldValuesQueryOptions = UseQueryOptions<
FieldValuesResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetrytypesTelemetryFieldValuesDTO
> & { queryKey: QueryKey };
const EMPTY_FIELD_VALUES: TelemetrytypesTelemetryFieldValuesDTO = {};
export const toFieldValues = (
res: FieldValuesResponse | undefined,
): TelemetrytypesTelemetryFieldValuesDTO =>
res?.data?.values ?? EMPTY_FIELD_VALUES;
export const getFieldValuesQueryOptions = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
): FieldValuesQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_VALUES_SUGGESTION,
builderQueryType,
fieldValuesConfig,
],
queryFn: ({ signal }): Promise<FieldValuesResponse> =>
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, signal),
select: toFieldValues,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
});
export const useFieldValuesSuggestion = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
options?: Pick<FieldValuesQueryOptions, 'enabled'>,
): UseQueryResult<
TelemetrytypesTelemetryFieldValuesDTO,
ErrorType<RenderErrorResponseDTO>
> =>
useQuery({
...getFieldValuesQueryOptions(fieldValuesConfig, builderQueryType),
...options,
});

View File

@@ -9,19 +9,17 @@ import { Atom, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import { isRawRequest } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
@@ -30,8 +28,6 @@ import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
@@ -51,7 +47,6 @@ interface PanelEditorQueryBuilderProps {
*/
function PanelEditorQueryBuilder({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -63,7 +58,7 @@ function PanelEditorQueryBuilder({
const panelType = toPanelType(panelDefinition.kind);
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -91,13 +86,6 @@ function PanelEditorQueryBuilder({
[onStageRunQuery],
);
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
[panelDefinition.queryBuilderFields, signal],
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
@@ -109,11 +97,10 @@ function PanelEditorQueryBuilder({
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={!isListViewPanel}
fieldsConfig={panelDefinition.queryBuilderFields}
showTraceOperator={!isRawQuery}
version="v3"
isListViewPanel={isListViewPanel}
queryComponents={{}}
isRawQuery={isRawQuery}
signalSourceChangeEnabled
savePreviousQuery
/>
@@ -146,7 +133,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
return (
<div

View File

@@ -7,7 +7,6 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
*/
function QueryBuilderEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -16,7 +15,6 @@ function QueryBuilderEditorPane({
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}

View File

@@ -1,6 +1,4 @@
import { render, screen } from '@testing-library/react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
@@ -45,14 +43,10 @@ jest.mock('assets/Dashboard/PromQl', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
function renderBuilder(
panelKind: string,
signal: TelemetrytypesSignalDTO = TelemetrytypesSignalDTO.logs,
): void {
function renderBuilder(panelKind: string): void {
render(
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}
onCancelQuery={jest.fn()}
@@ -62,9 +56,9 @@ function renderBuilder(
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
isRawQuery: boolean;
showTraceOperator: boolean;
filterConfigs: unknown;
fieldsConfig: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
@@ -80,7 +74,7 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
});
it('shows only the Query Builder tab for the List kind', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
renderBuilder('signoz/ListPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
@@ -114,40 +108,24 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
});
it('passes empty field config + non-list flag for a non-list kind', () => {
renderBuilder('signoz/TimeSeriesPanel', TelemetrytypesSignalDTO.metrics);
renderBuilder('signoz/TimeSeriesPanel');
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
expect(props.isRawQuery).toBe(false);
// The trace operator combines aggregated trace queries, so it rides along with
// the aggregation controls.
expect(props.showTraceOperator).toBe(true);
expect(props.filterConfigs).toStrictEqual({});
expect(props.fieldsConfig).toStrictEqual({});
});
it('hides step interval / having and sets body-contains for List + logs', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
it('marks List raw and leaves the field surface to the raw baseline', () => {
renderBuilder('signoz/ListPanel');
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.isRawQuery).toBe(true);
expect(props.showTraceOperator).toBe(false);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
it('additionally hides limit for List + traces', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.traces);
const props = lastQueryBuilderProps();
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
expect(props.fieldsConfig).toStrictEqual({});
});
});

View File

@@ -86,7 +86,6 @@ function EditorRoute(): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}

View File

@@ -2,7 +2,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
@@ -10,7 +9,6 @@ import { getPanelDefinition, isPanelKindSupported } from '../registry';
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryTypes,
@@ -144,7 +142,6 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
});
@@ -267,37 +264,4 @@ describe('panel capabilities guard', () => {
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
describe('getHiddenQueryBuilderFields', () => {
it('returns {} for kinds that declare no field rules', () => {
expect(
getHiddenQueryBuilderFields('signoz/TimeSeriesPanel', logs),
).toStrictEqual({});
expect(getHiddenQueryBuilderFields('signoz/TablePanel', logs)).toStrictEqual(
{},
);
});
// Mirrors QueryBuilderV2's internal listViewLogFilterConfigs — the guard is the
// single source of truth for these values.
it('hides step interval / having and sets body-contains for List + logs', () => {
expect(getHiddenQueryBuilderFields('signoz/ListPanel', logs)).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
// Mirrors listViewTracesFilterConfigs — traces additionally hide `limit`.
it('additionally hides limit for List + traces', () => {
expect(
getHiddenQueryBuilderFields('signoz/ListPanel', traces),
).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
});
});
});
});

View File

@@ -1,19 +1,15 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { type TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import {
mergeQueryBuilderFieldRule,
type FilterConfigsPartial,
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
/**
* The single deterministic guard for V2 dashboards. Every "what works with what"
* question — panel kind × query type × signal, and which query-builder fields a kind
* hides — is answered here by reading each kind's declared capabilities from the panel
* registry. Adding a new kind means declaring its capabilities once in its definition;
* question — panel kind × query type × signal — is answered here by reading each kind's
* declared capabilities from the panel registry. Adding a new kind means declaring its
* capabilities once in its definition;
* these functions then cover it automatically. Pure and side-effect free.
*/
@@ -122,16 +118,3 @@ export function resolveQueryType(
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
}
/**
* Query-builder field visibility for a kind + signal: the kind's `default` rule with
* its per-signal overrides merged over it (signal wins). `{}` when the kind hides
* nothing, i.e. the builder shows every field.
*/
export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
return mergeQueryBuilderFieldRule(rule, signal);
}

View File

@@ -19,7 +19,6 @@ function ListEditorPane({
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}

View File

@@ -8,7 +8,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
@@ -23,21 +22,9 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Raw rows have no aggregation, so step interval / having never apply, and the
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
// list configs — the capabilities guard is the single source for both.
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
},
[TelemetrytypesSignalDTO.traces]: {
limit: { isHidden: true, isDisabled: true },
},
},
// No deviation from the baseline the raw request type below already implies.
queryBuilderFields: {},
sections,
// The only kind reading raw rows: they page server-side, and the sort needs a
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.

View File

@@ -1,35 +1,6 @@
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Query-builder field-visibility config a panel kind can declare, mirroring the
* shape `QueryBuilderV2` consumes via its `filterConfigs` prop. Derived from that
* prop type (the underlying `FilterConfigs` isn't exported) so the two never drift.
*/
export type FilterConfigsPartial = NonNullable<
QueryBuilderProps['filterConfigs']
>;
/**
* Per-signal query-builder field rules for a panel kind. `default` applies to every
* signal; a per-signal entry is merged over it (signal wins). The capabilities guard
* resolves this into a single `FilterConfigsPartial` via `getHiddenQueryBuilderFields`.
*/
export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
export function mergeQueryBuilderFieldRule(
rule: QueryBuilderFieldRule,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}
export type { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
/**
* How a kind's query-range request is shaped. Declared per-kind in
@@ -57,3 +28,8 @@ export interface PanelQueryCapabilities {
*/
serverPaginated: boolean;
}
/** Raw rows rather than an aggregated result — the single source for "is this raw?". */
export function isRawRequest(capabilities: PanelQueryCapabilities): boolean {
return capabilities.requestType === Querybuildertypesv5RequestTypeDTO.raw;
}

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