Compare commits

...

16 Commits

Author SHA1 Message Date
therealpandey
3de6f2e72c refactor(frontend): remove the old logs explorer
The page and its redux slice were the only callers of the logs list and
aggregate endpoints removed in the previous commit, so it rendered
nothing. Dropping it takes the logs store, the logql parser and the
legacy log api clients with it.

LogViewMode moves to container/OptionsMenu/types and the restricted-field
constants to container/LogDetailedView/config, their only remaining
consumers.
2026-09-18 22:25:03 +05:30
therealpandey
be9170ced6 refactor: remove stubbed logs list and aggregate endpoints
GET /api/v1/logs and GET /api/v1/logs/aggregate have returned empty
payloads since #8299; drop the routes and the response models that only
existed to serve them.
2026-09-18 22:24:51 +05:30
Naman Verma
2068482f66 feat: add api to repair malformed channels created via v1 (#12910)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
<!--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

No need to write a db migration, notification channels can be repaired
if user asks to repair.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/pulse-pod/issues/342
2026-09-18 16:05:41 +00:00
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
Nityananda Gohain
c04d76ddb6 chore: use genai semconv for ai-o11y (#12905)
#### Description
Updates the key names based on
https://github.com/SigNoz/signoz-semantic-conventions/pull/7/changes
2026-09-18 05:56:28 +00:00
Pandey
67895d366d fix(tokenizer): require a jwt secret when the provider is jwt (#12899)
#### Description

- `tokenizer.Config.Validate()` now rejects an empty
`tokenizer::jwt::secret` when `tokenizer::provider` is `jwt`. An empty
secret signs and verifies tokens with an empty key, so anyone can mint a
valid token.
- Drops the startup log in `jwttokenizer` that flagged the missing
secret and carried on, config validation now fails the boot instead.

#### Additional Information

Breaking change: a deployment running `tokenizer.provider: jwt` without
`SIGNOZ_TOKENIZER_JWT_SECRET` (or the deprecated `SIGNOZ_JWT_SECRET`)
will fail to start until a secret is set. The default provider is
`opaque`, which is unaffected.
2026-09-18 04:06:28 +00:00
Pandey
b02aae2db3 feat(tokenizer): default to the opaque provider (#12896)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Switch the default tokenizer provider from `jwt` to `opaque`, so new
deployments issue revocable, server-side tokens out of the box.
- Update `conf/example.yaml` to match the new default.

#### Additional Information

Breaking change for deployments relying on the implicit default:
sessions issued by the JWT tokenizer are not valid for the opaque
tokenizer, so users will be logged out unless `tokenizer.provider: jwt`
is set explicitly.
2026-09-17 19:44:09 +00:00
Srikanth Chekuri
66b02d40c3 refactor(prometheus)!: remove the v1 provider and let the provider ow… (#12840)
…n evaluation

Assisted-by: Claude Fable 5
2026-09-17 18:25:58 +00:00
Nikhil Soni
9f03fea0f3 fix(querybuilder): prefer resource over any context for ambiguous filter keys (#12888)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- A logs filter on a bare key that lives in **both** resource and
another context (body or scope) ANDed the two: the resource candidate
built the `__resource_filter` fingerprint CTE while the other candidate
landed as a required main-query term, so the query matched almost
nothing.
- `ResolveLogicalFields` only preferred resource over `attribute`.
Generalized it to prefer resource over **any** other context (attribute,
body, scope, …); other contexts stay reachable via their qualified names
(e.g. `body.service.name`).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#6086
Part of https://github.com/SigNoz/platform-pod/issues/3158

#### Additional Information

Generalized rather than special-casing body/scope, since any future
context would hit the same fingerprint-CTE trap.
2026-09-17 14:45:45 +00:00
Naman Verma
d1a382945c fix: read Bearer/bearer/BEARER properly in v2 for webhook notification channels (#12890)
<!--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

Webhook notification channels with a bearer token authorisation work in
v1 with all three spellings `Bearer/bearer/BEARER`, but v2 API was not
accepting anything other than `Bearer`. This PR changes the conversion
from receiver -> gettable flow.

Also, error messages are made better in 2 places.
2026-09-17 11:57:11 +00:00
Nikhil Soni
59af5e0367 refactor(telemetrystore): drop app-side bulk-filtering override (#12871)
#### Description

- Stop managing `secondary_indices_enable_bulk_filtering` from the app.
It was hardcoded to `false` in the query hook as a workaround for
[ClickHouse#82283](https://github.com/ClickHouse/ClickHouse/issues/82283)
(`CANNOT_READ_ALL_DATA` with SET-type skip indexes).
- That bug is fixed
([ClickHouse#87817](https://github.com/ClickHouse/ClickHouse/pull/87817),
backported to 25.7/25.8/25.9) and prod runs 25.12. Verified on a local
25.12.5 container against `signoz_index_v3` that the crash no longer
reproduces with bulk filtering enabled.
- Removes the hook override plus the now-unused config field and
`example.yaml` entry. The setting reverts to being controlled
server-side via ClickHouse profiles (the charts change tracked in the
same issue).

#### Issues closed by this PR

Closes SigNoz/engineering-pod#5915

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-17 09:40:26 +00:00
Pandey
8286e787b2 fix(tracefunnel): quote step names in slow and error trace queries (#12886)
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

- `#12593` moved the n-step trace-funnel query builders onto
`clickhousesql.StringLiteral`, but the two-step `slow-traces` and
`error-traces` builders still interpolated `service_name`/`span_name`
into the SQL string literal raw.
- Route those four values through the same helper, so every funnel query
builder quotes step names consistently.

#### Additional Information

- No behaviour change for ordinary names; the
`slow-traces`/`error-traces` funnel queries now handle names containing
a quote the same way the rest of the module already does.
2026-09-17 07:01:21 +00:00
283 changed files with 4416 additions and 9043 deletions

View File

@@ -202,7 +202,6 @@ telemetrystore:
max_bytes_to_read: 0
max_result_rows: 0
ignore_data_skipping_indices: ""
secondary_indices_enable_bulk_filtering: false
##################### Prometheus #####################
prometheus:
@@ -342,7 +341,7 @@ gateway:
##################### Tokenizer #####################
tokenizer:
# Specifies the tokenizer provider to use.
provider: jwt
provider: opaque
lifetime:
# The duration for which a user can be idle before being required to authenticate.
idle: 168h

View File

@@ -171,6 +171,14 @@ components:
- kind
- spec
type: object
AlertmanagertypesChannelDefect:
enum:
- none
- missing_type
- multiple_notifiers
- unsupported_notifier
- unrepresentable
type: string
AlertmanagertypesChannelEmailConfig:
properties:
headers:
@@ -384,13 +392,83 @@ components:
required:
- routingKey
type: object
AlertmanagertypesChannelRepair:
properties:
action:
$ref: '#/components/schemas/AlertmanagertypesChannelRepairAction'
applied:
type: boolean
blockers:
items:
type: string
type: array
channels:
items:
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
nullable: true
type: array
defect:
$ref: '#/components/schemas/AlertmanagertypesChannelDefect'
detail:
type: string
id:
type: string
required:
- id
- defect
- action
- applied
type: object
AlertmanagertypesChannelRepairAction:
enum:
- none
- retype
- split
- delete
type: string
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 +476,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:
@@ -969,6 +1075,11 @@ components:
- duration
- repeatType
type: object
AlertmanagertypesRepairChannelParams:
properties:
apply:
type: boolean
type: object
AlertmanagertypesRepeatOn:
enum:
- sunday
@@ -20242,6 +20353,85 @@ paths:
summary: Update notification channel
tags:
- channels
/api/v2/notification_channels/{id}/repair:
post:
deprecated: false
description: 'This endpoint diagnoses a stored channel that the v2 API cannot
read and applies the fitting action: a channel carrying several notifier configurations
is split into one channel per configuration, keeping this ID for the first;
a channel whose notifier kind v2 does not model is deleted; a channel with
an empty stored type has it rewritten from its data. A delete is refused while
a routing policy still names the channel. Nothing is written unless apply=true;
by default the response only shows what would happen.'
operationId: RepairNotificationChannel
parameters:
- in: query
name: apply
schema:
type: boolean
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesRepairChannelParams'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesChannelRepair'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:update
- tokenizer:
- notification-channel:update
summary: Repair notification channel
tags:
- channels
/api/v2/notification_channels/test:
post:
deprecated: false

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

@@ -17,7 +17,7 @@ For example, the [prometheus](/pkg/prometheus) provider delivers a prometheus en
- `pkg/prometheus/prometheus.go` - Interface definition
- `pkg/prometheus/config.go` - Configuration
- `pkg/prometheus/clickhouseprometheus/provider.go` - Clickhouse-powered implementation
- `pkg/prometheus/clickhouseprometheusv2/provider.go` - Clickhouse-powered implementation
- `pkg/prometheus/prometheustest/provider.go` - Mock implementation
## How to wire it up?

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

@@ -9,15 +9,16 @@ change breaks an invariant, flag it and discuss it first.
---
## Why a second provider
## Why the provider looks like this
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql
engine through the remote-read protobuf adapter. It fetches every raw sample
of a query's union window. It serializes all of them and gives them to the
engine. The cost follows the ingested data, not the question. This is how a
dashboard of PromQL panels can take an instance down.
The removed v1 provider served the promql engine through the remote-read
protobuf adapter. It fetched every raw sample of a query's union window,
serialized all of them, and gave them to the engine. The cost followed the
ingested data, not the question. This is how a dashboard of PromQL panels
could take an instance down. v2 replaced it after a byte-level parity
rollout, and v1 was then deleted.
In v2, each query runs in one of two ways. The classifier decides per query:
Each query runs in one of two ways. The classifier decides per query:
- **Transpiled**: ClickHouse evaluates the query. Only final (or near-final)
per-group grid arrays come back. The statements use the
@@ -30,7 +31,7 @@ In v2, each query runs in one of two ways. The classifier decides per query:
lost user. A construct that cannot reproduce engine semantics exactly falls
back. It does not approximate.** The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against both providers. It is the arbiter. The classification golden
corpus against the provider. It is the arbiter. The classification golden
(`testdata/classification_golden.json`) freezes the route of each corpus
expression. The rest of this document is the PromQL-to-SQL story. That
mapping is where correctness is won or lost.
@@ -263,7 +264,8 @@ per-thread partials scaled memory with the thread count. The slide then
combines each slot's at-most-W bucket partials by direct aggregation
(`arraySum(arraySlice(...))`). Window sums are added the way the engine adds
them. There is no prefix-sum differencing: its large-minus-large
cancellation would drift past the shadow tolerance on counter-sized values.
cancellation would drift past the conformance tolerance on counter-sized
values.
This is correct per slot because the bucket union is the exact window
multiset, and avg/min/max/sum/count are order-insensitive on a multiset
(sum/avg up to summation order; see the float caveat above). A slot with
@@ -333,7 +335,7 @@ can carry them.
## The engine path
Queries that do not transpile run in the stock engine over this package's
`storage.Querier`. This is still not the v1 path. Samples are fetched per
`storage.Querier`. Samples are fetched per
selector with the engine's per-selector hints, not the query-wide union
window. So `foo / foo offset 1d` reads two narrow windows, not the widest
one twice. Instant selectors of subquery-free queries fetch only the last
@@ -367,9 +369,9 @@ same predicates as a shard-local semi-join, not a GLOBAL broadcast of the
matched set. The temporality filter on every samples statement is a
semantic no-op: the matched fingerprints already come from those
temporalities. It engages the leading samples primary-key column.
Delta-temporality series stay invisible to PromQL here, exactly as in v1.
The rollout gate is parity with v1. To make Delta visible is its own change
with its own semantics to design. A Delta stream fed to `rate()`
Delta-temporality series stay invisible to PromQL here, as they were before
v2. To make Delta visible is its own change with its own semantics to
design. A Delta stream fed to `rate()`
as-if-cumulative would be wrong, not just new.
## Observability

View File

@@ -160,7 +160,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
// Variable to store promProvider for cleanup
var promProvider *prometheustest.Provider
var promProvider prometheus.Prometheus
// Create manager using test factory with hooks
mgr := rules.NewTestManager(t, &rules.TestManagerOptions{
@@ -185,76 +185,29 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
TelemetryStoreHook: func(store telemetrystore.TelemetryStore) {
mockStore := store.(*telemetrystoretest.Provider)
// Set up Prometheus-specific mock data
// Fingerprint columns for Prometheus queries
fingerprintCols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "any(labels)", Type: "String"},
}
// Samples columns for Prometheus queries
samplesCols := []cmock.ColumnType{
{Name: "metric_name", Type: "String"},
{Name: "fingerprint", Type: "UInt64"},
{Name: "unix_milli", Type: "Int64"},
{Name: "value", Type: "Float64"},
{Name: "flags", Type: "UInt32"},
}
// Calculate query time range similar to Prometheus rule tests
// TestNotification uses time.Now().UTC() for evaluation
// We calculate the query window based on current time to match what the actual evaluation will use
// Grid the TestNotification eval computes over (see
// Timestamps on base_rule); nil args match any window.
evalTime := baseTime
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
evalTimeMs := evalTime.UnixMilli()
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
gridStart := gridEnd - evalWindowMs
// Create fingerprint data
fingerprint := uint64(12345)
labelsJSON := `{"__name__":"test_metric"}`
fingerprintData := [][]interface{}{
{fingerprint, labelsJSON},
}
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
// Create samples data from test case values, calculating timestamps relative to baseTime
validSamplesData := make([][]interface{}, 0)
tsList := make([]int64, 0, len(tc.Values))
vList := make([]float64, 0, len(tc.Values))
for _, v := range tc.Values {
// Skip NaN and Inf values in the samples data
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
continue
}
// Calculate timestamp relative to baseTime
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
validSamplesData = append(validSamplesData, []interface{}{
"test_metric",
fingerprint,
sampleTimestamp,
v.Value,
uint32(0), // flags - 0 means normal value
})
tsList = append(tsList, baseTime.Add(v.Offset).UnixMilli())
vList = append(vList, v.Value)
}
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
grid := prometheustest.LastSampleGrid(tsList, vList, gridStart, gridEnd, 60_000, 300_000)
mock := mockStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
queryStart,
queryEnd,
).
WillReturnRows(samplesRows)
mock.ExpectQuery("SELECT gkey").
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
WillReturnRows(cmock.NewRows(prometheustest.GridCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
@@ -289,7 +242,6 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
assert.Empty(t, triggeredTestAlerts)
}
promProvider.Close()
})
}
}

View File

@@ -43,7 +43,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"OLD_LOGS_EXPLORER": "SigNoz | Old Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
"HOME_PAGE": "Open source Observability Platform | SigNoz",

View File

@@ -1602,10 +1602,6 @@ describe('PrivateRoute', () => {
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
OLD_LOGS_EXPLORER: {
path: ROUTES.OLD_LOGS_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER: {
path: ROUTES.METRICS_EXPLORER,
deniedRoles: DENIED_ROLES,

View File

@@ -169,10 +169,6 @@ export const LogsExplorer = Loadable(
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/LogsModulePage'),
);
export const OldLogsExplorer = Loadable(
() => import(/* webpackChunkName: "Logs Explorer" */ 'pages/Logs'),
);
export const LiveLogs = Loadable(
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
);

View File

@@ -32,7 +32,6 @@ import {
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
OnboardingV2,
OrgOnboarding,
PasswordReset,
@@ -308,13 +307,6 @@ const routes: AppRoutes[] = [
key: 'LOGS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.OLD_LOGS_EXPLORER,
exact: true,
component: OldLogsExplorer,
key: 'OLD_LOGS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.LIVE_LOGS,
exact: true,

View File

@@ -21,6 +21,7 @@ import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
AlertmanagertypesRepairChannelParamsDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
CreateChannel201,
@@ -35,6 +36,9 @@ import type {
ListNotificationChannels200,
ListNotificationChannelsParams,
RenderErrorResponseDTO,
RepairNotificationChannel200,
RepairNotificationChannelParams,
RepairNotificationChannelPathParameters,
UpdateChannelByIDPathParameters,
UpdateNotificationChannel200,
UpdateNotificationChannelPathParameters,
@@ -1144,6 +1148,113 @@ export const useUpdateNotificationChannel = <
> => {
return useMutation(getUpdateNotificationChannelMutationOptions(options));
};
/**
* This endpoint diagnoses a stored channel that the v2 API cannot read and applies the fitting action: a channel carrying several notifier configurations is split into one channel per configuration, keeping this ID for the first; a channel whose notifier kind v2 does not model is deleted; a channel with an empty stored type has it rewritten from its data. A delete is refused while a routing policy still names the channel. Nothing is written unless apply=true; by default the response only shows what would happen.
* @summary Repair notification channel
*/
export const repairNotificationChannel = (
{ id }: RepairNotificationChannelPathParameters,
alertmanagertypesRepairChannelParamsDTO?: BodyType<AlertmanagertypesRepairChannelParamsDTO>,
params?: RepairNotificationChannelParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<RepairNotificationChannel200>({
url: `/api/v2/notification_channels/${id}/repair`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesRepairChannelParamsDTO,
params,
signal,
});
};
export const getRepairNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
> => {
const mutationKey = ['repairNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof repairNotificationChannel>>,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
}
> = (props) => {
const { pathParams, data, params } = props ?? {};
return repairNotificationChannel(pathParams, data, params);
};
return { mutationFn, ...mutationOptions };
};
export type RepairNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof repairNotificationChannel>>
>;
export type RepairNotificationChannelMutationBody =
| BodyType<AlertmanagertypesRepairChannelParamsDTO>
| undefined;
export type RepairNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Repair notification channel
*/
export const useRepairNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
> => {
return useMutation(getRepairNotificationChannelMutationOptions(options));
};
/**
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
* @summary Test notification channel

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 {
@@ -506,6 +596,13 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
missing_type = 'missing_type',
multiple_notifiers = 'multiple_notifiers',
unsupported_notifier = 'unsupported_notifier',
unrepresentable = 'unrepresentable',
}
export enum AlertmanagertypesChannelKindDTO {
slack = 'slack',
email = 'email',
@@ -527,6 +624,63 @@ export enum AlertmanagertypesChannelListSortDTO {
created_at = 'created_at',
name = 'name',
}
export enum AlertmanagertypesChannelRepairActionDTO {
none = 'none',
retype = 'retype',
split = 'split',
delete = 'delete',
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesChannelRepairDTO {
action: AlertmanagertypesChannelRepairActionDTO;
/**
* @type boolean
*/
applied: boolean;
/**
* @type array
*/
blockers?: string[];
/**
* @type array,null
*/
channels?: AlertmanagertypesListedNotificationChannelDTO[] | null;
defect: AlertmanagertypesChannelDefectDTO;
/**
* @type string
*/
detail?: string;
/**
* @type string
*/
id: string;
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -1020,32 +1174,6 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
wont_fix_resolution?: string;
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesListableNotificationChannelDTO {
/**
* @type array
@@ -2449,6 +2577,13 @@ export interface AlertmanagertypesReceiverDTO {
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AlertmanagertypesRepairChannelParamsDTO {
/**
* @type boolean
*/
apply?: boolean;
}
export interface AlertmanagertypesTestableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
@@ -13394,6 +13529,25 @@ export type UpdateNotificationChannel200 = {
status: string;
};
export type RepairNotificationChannelPathParameters = {
id: string;
};
export type RepairNotificationChannelParams = {
/**
* @type boolean
* @description undefined
*/
apply?: boolean;
};
export type RepairNotificationChannel200 = {
data: AlertmanagertypesChannelRepairDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/addToSelectedFields';
const addToSelectedFields = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.post(`/logs/fields`, props);
return {
statusCode: 200,
error: null,
message: '',
payload: data.data,
};
} catch (error) {
return Promise.reject(ErrorResponseHandler(error as AxiosError));
}
};
export default addToSelectedFields;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/getLogs';
const GetLogs = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.get(`/logs`, {
params: props,
});
return {
statusCode: 200,
error: null,
message: '',
payload: data.data.results,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default GetLogs;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/getLogsAggregate';
const GetLogsAggregate = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.get(`/logs/aggregate`, {
params: props,
});
return {
statusCode: 200,
error: null,
message: '',
payload: data.data.items,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default GetLogsAggregate;

View File

@@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/logs/getSearchFields';
const GetSearchFields = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
try {
const data = await axios.get(`/logs/fields`);
return {
statusCode: 200,
error: null,
message: '',
payload: data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default GetSearchFields;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/logs/addToSelectedFields';
const removeSelectedField = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const data = await axios.post(`/logs/fields`, props);
return {
statusCode: 200,
error: null,
message: '',
payload: data.data,
};
} catch (error) {
return Promise.reject(ErrorResponseHandler(error as AxiosError));
}
};
export default removeSelectedField;

View File

@@ -1,22 +0,0 @@
import apiV1 from 'api/apiV1';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { ENVIRONMENT } from 'constants/env';
import { LOCALSTORAGE } from 'constants/localStorage';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { withBasePath } from 'utils/basePath';
// 10 min in ms
const TIMEOUT_IN_MS = 10 * 60 * 1000;
export const LiveTail = (queryParams: string): EventSourcePolyfill =>
new EventSourcePolyfill(
ENVIRONMENT.baseURL
? `${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`
: withBasePath(`${apiV1}logs/tail?${queryParams}`),
{
headers: {
Authorization: `Bearer ${getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN)}`,
},
heartbeatTimeout: TIMEOUT_IN_MS,
},
);

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

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
Check,

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

@@ -38,7 +38,6 @@ const ROUTES = {
NOT_FOUND: '/not-found',
LOGS_BASE: '/logs',
LOGS: '/logs/logs-explorer',
OLD_LOGS_EXPLORER: '/logs/old-logs-explorer',
LOGS_EXPLORER: '/logs/logs-explorer',
LIVE_LOGS: '/logs/logs-explorer/live',
LOGS_PIPELINES: '/logs/pipelines',

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,136 +0,0 @@
import { memo, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Button, Flex } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import Controls from 'container/Controls';
import Download from 'container/Download/Download';
import { getGlobalTime } from 'container/LogsSearchFilter/utils';
import dayjs from 'dayjs';
import { Pagination } from 'hooks/queryPagination';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { FlatLogData } from 'lib/logs/flatLogData';
import { OrderPreferenceItems } from 'pages/Logs/config';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
GET_NEXT_LOG_LINES,
GET_PREVIOUS_LOG_LINES,
RESET_ID_START_AND_END,
SET_LOG_LINES_PER_PAGE,
} from 'types/actions/logs';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ILogsReducer } from 'types/reducer/logs';
import { Container } from './styles';
import { SkipBack } from '@signozhq/icons';
function LogControls(): JSX.Element | null {
const {
logLinesPerPage,
liveTail,
isLoading: isLogsLoading,
isLoadingAggregate,
logs,
order,
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const dispatch = useDispatch<Dispatch<AppActions>>();
const handleLogLinesPerPageChange = (e: Pagination['limit']): void => {
dispatch({
type: SET_LOG_LINES_PER_PAGE,
payload: {
logsLinesPerPage: e,
},
});
};
const handleGoToLatest = (): void => {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
const updatedGlobalTime = getGlobalTime(globalTime.selectedTime, {
maxTime,
minTime,
});
if (updatedGlobalTime) {
dispatch({
type: RESET_ID_START_AND_END,
payload: updatedGlobalTime,
});
}
};
const handleNavigatePrevious = (): void => {
dispatch({
type: GET_PREVIOUS_LOG_LINES,
});
};
const handleNavigateNext = (): void => {
dispatch({
type: GET_NEXT_LOG_LINES,
});
};
const flattenLogData = useMemo(
() =>
logs.map((log) => {
const timestamp =
typeof log.timestamp === 'string'
? dayjs(log.timestamp).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(log.timestamp / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
return FlatLogData({
...log,
timestamp,
});
}),
[logs],
);
const isLoading = isLogsLoading || isLoadingAggregate;
if (liveTail !== 'STOPPED') {
return null;
}
return (
<Container>
<Download data={flattenLogData} isLoading={isLoading} fileName="log_data" />
<Button
loading={isLoading}
size="small"
type="link"
disabled={order === OrderPreferenceItems.ASC}
onClick={handleGoToLatest}
>
<Flex align="center" gap="4px">
<SkipBack size="md" /> Go to latest
</Flex>
</Button>
<Divider type="vertical" />
<Controls
isLoading={isLoading}
totalCount={logs.length}
countPerPage={logLinesPerPage}
handleNavigatePrevious={handleNavigatePrevious}
handleNavigateNext={handleNavigateNext}
handleCountItemsPerPageChange={handleLogLinesPerPageChange}
/>
</Container>
);
}
export default memo(LogControls);

View File

@@ -1,14 +0,0 @@
import { Button } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
`;
export const DownloadLogButton = styled(Button)`
display: flex;
align-items: center;
`;

View File

@@ -1,6 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { generatePath } from 'react-router-dom';
import { Link, Pin } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
@@ -14,23 +12,19 @@ import { ResizeTable } from 'components/ResizeTable';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
import { removeJSONStringifyQuotes } from 'lib/removeJSONStringifyQuotes';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import AppActions from 'types/actions';
import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import { RESTRICTED_SELECTED_FIELDS } from './config';
import FieldRenderer from './FieldRenderer';
import TableViewActions from './TableView/TableViewActions';
import {
@@ -65,7 +59,6 @@ function TableView({
listViewPanelSelectedFields,
handleChangeSelectedView,
}: Props): JSX.Element | null {
const dispatch = useDispatch<Dispatch<AppActions>>();
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isfilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const isDarkMode = useIsDarkMode();
@@ -185,11 +178,6 @@ function TableView({
const spanId = flattenLogData?.span_id;
if (traceId) {
dispatch({
type: SET_DETAILED_LOG_DATA,
payload: null,
});
const basePath = generatePath(ROUTES.TRACE_DETAIL, {
id: traceId,
});

View File

@@ -10,7 +10,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
@@ -27,6 +26,7 @@ import {
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { RESTRICTED_SELECTED_FIELDS } from '../config';
import { DataType } from '../TableView';
import {
filterKeyForField,
@@ -141,10 +141,9 @@ export default function TableViewActions(
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page or infra monitoring
const isOldLogsExplorerOrLiveLogsPage = useMemo(
// there is no option for where clause in live logs page or infra monitoring
const isLiveLogsOrInfraPage = useMemo(
() =>
pathname === ROUTES.OLD_LOGS_EXPLORER ||
pathname === ROUTES.LIVE_LOGS ||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_HOSTS ||
pathname === ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
@@ -400,7 +399,7 @@ export default function TableViewActions(
)}
/>
</Tooltip>
{!isOldLogsExplorerOrLiveLogsPage && (
{!isLiveLogsOrInfraPage && (
<Popover
open={isOpen}
onOpenChange={setIsOpen}
@@ -487,7 +486,7 @@ export default function TableViewActions(
)}
/>
</Tooltip>
{!isOldLogsExplorerOrLiveLogsPage && (
{!isLiveLogsOrInfraPage && (
<Popover
open={isOpen}
onOpenChange={setIsOpen}

View File

@@ -1,9 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { RESTRICTED_SELECTED_FIELDS } from '../../config';
import TableViewActions from '../TableViewActions';
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';

View File

@@ -1,5 +1,10 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
// Fields that can be filtered on but not grouped by in the log details view.
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
export const typeToArrayTypeMapper: { [key in DataTypes]: DataTypes } = {
[DataTypes.String]: DataTypes.ArrayString,
[DataTypes.Float64]: DataTypes.ArrayFloat64,

View File

@@ -61,8 +61,7 @@ export function useLogAttributeActions({
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isOldExplorerOrLive =
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
const isLiveLogs = pathname === ROUTES.LIVE_LOGS;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
@@ -221,7 +220,7 @@ export function useLogAttributeActions({
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported ||
isOldExplorerOrLive,
isLiveLogs,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -229,9 +228,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
isRestricted(fieldKeyPath) ||
isOldExplorerOrLive,
!handleChangeSelectedView || isRestricted(fieldKeyPath) || isLiveLogs,
},
];
}, [
@@ -239,7 +236,7 @@ export function useLogAttributeActions({
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
isLiveLogs,
handleChangeSelectedView,
onApplyLogFilter,
]);

View File

@@ -1,168 +0,0 @@
import { memo, useCallback } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect, useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ROUTES from 'constants/routes';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import getStep from 'lib/getStep';
import { getIdConditions } from 'pages/Logs/utils';
// eslint-disable-next-line no-restricted-imports
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { getLogs } from 'store/actions/logs/getLogs';
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
SET_DETAILED_LOG_DATA,
SET_SEARCH_QUERY_STRING,
TOGGLE_LIVE_TAIL,
} from 'types/actions/logs';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ILogsReducer } from 'types/reducer/logs';
type LogDetailedViewProps = {
getLogs: (props: Parameters<typeof getLogs>[0]) => ReturnType<typeof getLogs>;
getLogsAggregate: (
props: Parameters<typeof getLogsAggregate>[0],
) => ReturnType<typeof getLogsAggregate>;
};
function LogDetailedView({
getLogs,
getLogsAggregate,
}: LogDetailedViewProps): JSX.Element {
const history = useHistory();
const {
detailedLog,
searchFilter: { queryString },
logLinesPerPage,
idStart,
liveTail,
idEnd,
order,
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const dispatch = useDispatch<Dispatch<AppActions>>();
const onDrawerClose = (): void => {
dispatch({
type: SET_DETAILED_LOG_DATA,
payload: null,
});
};
const handleAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string) => {
const newOperator = getOldLogsOperatorFromNew(operator);
const updatedQueryString = getGeneratedFilterQueryString(
fieldKey,
fieldValue,
newOperator,
queryString,
);
history.replace(`${ROUTES.OLD_LOGS_EXPLORER}?q=${updatedQueryString}`);
},
[history, queryString],
);
const handleClickActionItem = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
const newOperator = getOldLogsOperatorFromNew(operator);
const updatedQueryString = getGeneratedFilterQueryString(
fieldKey,
fieldValue,
newOperator,
queryString,
);
dispatch({
type: SET_SEARCH_QUERY_STRING,
payload: {
searchQueryString: updatedQueryString,
},
});
if (liveTail === 'STOPPED') {
getLogs({
q: updatedQueryString,
limit: logLinesPerPage,
orderBy: 'timestamp',
order,
timestampStart: minTime,
timestampEnd: maxTime,
...getIdConditions(idStart, idEnd, order),
});
getLogsAggregate({
timestampStart: minTime,
timestampEnd: maxTime,
step: getStep({
start: minTime,
end: maxTime,
inputFormat: 'ns',
}),
q: updatedQueryString,
});
} else if (liveTail === 'PLAYING') {
dispatch({
type: TOGGLE_LIVE_TAIL,
payload: 'PAUSED',
});
setTimeout(
() =>
dispatch({
type: TOGGLE_LIVE_TAIL,
payload: liveTail,
}),
0,
);
}
},
[
dispatch,
getLogs,
getLogsAggregate,
idEnd,
idStart,
liveTail,
logLinesPerPage,
maxTime,
minTime,
order,
queryString,
],
);
return (
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={detailedLog}
onClose={onDrawerClose}
onAddToQuery={handleAddToQuery}
onClickActionItem={handleClickActionItem}
/>
);
}
interface DispatchProps {
getLogs: (props: Parameters<typeof getLogs>[0]) => (dispatch: never) => void;
getLogsAggregate: (
props: Parameters<typeof getLogsAggregate>[0],
) => (dispatch: never) => void;
}
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
getLogs: bindActionCreators(getLogs, dispatch),
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
});
export default connect(null, mapDispatchToProps)(memo(LogDetailedView as any));

View File

@@ -5,10 +5,6 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import {
RESTRICTED_GROUP_BY_FIELDS,
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
@@ -18,6 +14,10 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
RESTRICTED_GROUP_BY_FIELDS,
RESTRICTED_SELECTED_FIELDS,
} from './config';
import { LogAttributeBucket } from './constants';
import { generateFieldKeyForArray, getDataTypes } from './utils';

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

@@ -1,26 +0,0 @@
export const TIME_PICKER_OPTIONS = [
{
value: 5,
label: '5m',
},
{
value: 15,
label: '15m',
},
{
value: 30,
label: '30m',
},
{
value: 60,
label: '1hr',
},
{
value: 360,
label: '6hrs',
},
{
value: 720,
label: '12hrs',
},
];

View File

@@ -1,270 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect, useDispatch, useSelector } from 'react-redux';
import { green } from '@ant-design/colors';
import { Pause, Play, EllipsisVertical } from '@signozhq/icons';
import { Button, Flex, Popover, Select, Space } from 'antd';
import { LiveTail } from 'api/logs/livetail';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import getStep from 'lib/getStep';
import { throttle } from 'lodash-es';
// eslint-disable-next-line no-restricted-imports
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_AUTO_REFRESH_DISABLED } from 'types/actions/globalTime';
import {
FLUSH_LOGS,
PUSH_LIVE_TAIL_EVENT,
SET_LIVE_TAIL_START_TIME,
SET_LOADING,
TOGGLE_LIVE_TAIL,
} from 'types/actions/logs';
import { TLogsLiveTailState } from 'types/api/logs/liveTail';
import { ILog } from 'types/api/logs/log';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ILogsReducer } from 'types/reducer/logs';
import { popupContainer } from 'utils/selectPopupContainer';
import { TIME_PICKER_OPTIONS } from './config';
import { StopContainer, TimePickerCard, TimePickerSelect } from './styles';
function LogLiveTail({ getLogsAggregate }: Props): JSX.Element {
const {
liveTail,
searchFilter: { queryString },
liveTailStartRange,
logs,
idEnd,
idStart,
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
const isDarkMode = useIsDarkMode();
const { selectedAutoRefreshInterval } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { notifications } = useNotifications();
const dispatch = useDispatch<Dispatch<AppActions>>();
const handleLiveTail = (toggleState: TLogsLiveTailState): void => {
dispatch({
type: TOGGLE_LIVE_TAIL,
payload: toggleState,
});
dispatch({
type: UPDATE_AUTO_REFRESH_DISABLED,
payload: toggleState === 'PLAYING',
});
};
const batchedEventsRef = useRef<ILog[]>([]);
const pushLiveLog = useCallback(() => {
dispatch({
type: PUSH_LIVE_TAIL_EVENT,
payload: batchedEventsRef.current.reverse(),
});
batchedEventsRef.current = [];
}, [dispatch]);
const pushLiveLogThrottled = useMemo(
() => throttle(pushLiveLog, 1000),
[pushLiveLog],
);
const batchLiveLog = useCallback(
(e: { data: string }): void => {
batchedEventsRef.current.push(JSON.parse(e.data as string) as never);
pushLiveLogThrottled();
},
[pushLiveLogThrottled],
);
const firstLogsId = useMemo(() => logs[0]?.id, [logs]);
// This ref depicts thats whether the live tail is played from paused state or not.
const liveTailSourceRef = useRef<EventSource>();
useEffect(() => {
if (liveTail === 'PLAYING') {
const timeStamp = dayjs().subtract(liveTailStartRange, 'minute').valueOf();
const queryParams = new URLSearchParams({
...(queryString ? { q: queryString } : {}),
timestampStart: (timeStamp * 1e6) as never,
...(liveTailSourceRef.current && firstLogsId
? {
idGt: firstLogsId,
}
: {}),
});
if (liveTailSourceRef.current) {
liveTailSourceRef.current.close();
}
const source = LiveTail(queryParams.toString());
liveTailSourceRef.current = source;
source.onmessage = function connectionMessage(e): void {
batchLiveLog(e);
};
source.onerror = function connectionError(event: unknown): void {
console.error(event);
source.close();
dispatch({
type: TOGGLE_LIVE_TAIL,
payload: 'STOPPED',
});
dispatch({
type: SET_LOADING,
payload: false,
});
notifications.error({
message: 'Live tail stopped due to some error.',
});
};
}
if (liveTail === 'STOPPED') {
liveTailSourceRef.current = undefined;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [liveTail, queryString, notifications, dispatch]);
const handleLiveTailStart = (): void => {
handleLiveTail('PLAYING');
const startTime =
dayjs().subtract(liveTailStartRange, 'minute').valueOf() * 1e6;
const endTime = dayjs().valueOf() * 1e6;
getLogsAggregate({
timestampStart: startTime,
timestampEnd: endTime,
step: getStep({
start: startTime,
end: endTime,
inputFormat: 'ns',
}),
q: queryString,
...(idStart ? { idGt: idStart } : {}),
...(idEnd ? { idLt: idEnd } : {}),
});
if (!liveTailSourceRef.current) {
dispatch({
type: FLUSH_LOGS,
});
}
};
const OptionsPopOverContent = useMemo(
() => (
<TimePickerSelect
getPopupContainer={popupContainer}
disabled={liveTail === 'PLAYING'}
value={liveTailStartRange}
onChange={(value): void => {
if (typeof value === 'number') {
dispatch({
type: SET_LIVE_TAIL_START_TIME,
payload: value,
});
}
}}
>
{TIME_PICKER_OPTIONS.map((optionData) => (
<Select.Option key={optionData.label} value={optionData.value}>
Last {optionData.label}
</Select.Option>
))}
</TimePickerSelect>
),
[dispatch, liveTail, liveTailStartRange],
);
const isDisabled = useMemo(
() => selectedAutoRefreshInterval?.length > 0,
[selectedAutoRefreshInterval],
);
const onLiveTailStop = (): void => {
handleLiveTail('STOPPED');
dispatch({
type: UPDATE_AUTO_REFRESH_DISABLED,
payload: false,
});
dispatch({
type: SET_LOADING,
payload: false,
});
if (liveTailSourceRef.current) {
liveTailSourceRef.current.close();
}
};
return (
<TimePickerCard>
<Space size={0} align="center">
{liveTail === 'PLAYING' ? (
<Button
type="primary"
onClick={onLiveTailStop}
title="Pause live tail"
style={{ background: green[6] }}
>
<Flex align="center" gap={4}>
<span>Pause</span>
<Pause size="md" />
</Flex>
</Button>
) : (
<Button
type="primary"
onClick={handleLiveTailStart}
title="Start live tail"
disabled={isDisabled}
>
<Flex align="center" gap={4}>
Go Live <Play size="md" />
</Flex>
</Button>
)}
{liveTail !== 'STOPPED' && (
<Button type="dashed" onClick={onLiveTailStop} title="Exit live tail">
<StopContainer isDarkMode={isDarkMode} />
</Button>
)}
<Popover
getPopupContainer={popupContainer}
placement="bottomRight"
title="Select Live Tail Timing"
trigger="click"
content={OptionsPopOverContent}
>
<EllipsisVertical size="lg" />
</Popover>
</Space>
</TimePickerCard>
);
}
interface DispatchProps {
getLogsAggregate: typeof getLogsAggregate;
}
type Props = DispatchProps;
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
});
export default connect(null, mapDispatchToProps)(LogLiveTail);

View File

@@ -1,25 +0,0 @@
import { Card, Select } from 'antd';
import styled from 'styled-components';
export const TimePickerCard = styled(Card)`
.ant-card-body {
display: flex;
padding: 0;
}
`;
export const TimePickerSelect = styled(Select)`
min-width: 100px;
`;
interface Props {
isDarkMode: boolean;
}
export const StopContainer = styled.div<Props>`
height: 0.8rem;
width: 0.8rem;
border-radius: 0.1rem;
background-color: ${({ isDarkMode }): string =>
isDarkMode ? '#fff' : '#000'};
`;

View File

@@ -1,95 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect, useSelector } from 'react-redux';
import { blue } from '@ant-design/colors';
import Graph from 'components/Graph';
import Spinner from 'components/Spinner';
import dayjs from 'dayjs';
import useInterval from 'hooks/useInterval';
import getStep from 'lib/getStep';
// eslint-disable-next-line no-restricted-imports
import { bindActionCreators } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { ILogsReducer } from 'types/reducer/logs';
import { Container } from './styles';
function LogsAggregate({ getLogsAggregate }: DispatchProps): JSX.Element {
const {
searchFilter: { queryString },
idEnd,
idStart,
isLoadingAggregate,
logsAggregate,
liveTail,
liveTailStartRange,
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
useInterval(
() => {
const startTime =
dayjs().subtract(liveTailStartRange, 'minute').valueOf() * 1e6;
const endTime = dayjs().valueOf() * 1e6;
getLogsAggregate({
timestampStart: startTime,
timestampEnd: endTime,
step: getStep({
start: startTime,
end: endTime,
inputFormat: 'ns',
}),
q: queryString,
...(idStart ? { idGt: idStart } : {}),
...(idEnd ? { idLt: idEnd } : {}),
});
},
60000,
liveTail === 'PLAYING',
);
const graphData = useMemo(
() => ({
labels: logsAggregate.map((s) => new Date(s.timestamp / 1000000)),
datasets: [
{
data: logsAggregate.map((s) => s.value),
backgroundColor: blue[4],
},
],
}),
[logsAggregate],
);
return (
<Container>
{isLoadingAggregate ? (
<Spinner size="default" height="100%" />
) : (
<Graph
name="usage"
data={graphData}
type="bar"
containerHeight="100%"
animate
/>
)}
</Container>
);
}
interface DispatchProps {
getLogsAggregate: typeof getLogsAggregate;
}
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
});
export default connect(null, mapDispatchToProps)(LogsAggregate);

View File

@@ -1,11 +0,0 @@
import { Card } from 'antd';
import styled from 'styled-components';
export const Container = styled(Card)`
position: relative;
margin: 0.5rem 0;
.ant-card-body {
height: 20vh;
min-height: 200px;
}
`;

View File

@@ -1,93 +0,0 @@
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { Loader } from '@signozhq/icons';
import { Button, Popover, Spin } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
IField,
IInterestingFields,
ISelectedFields,
} from 'types/api/logs/fields';
import { ICON_STYLE } from './config';
import { Field } from './styles';
function FieldItem({
name,
buttonIcon,
buttonOnClick,
fieldData,
fieldIndex,
isLoading,
iconHoverText,
}: FieldItemProps): JSX.Element {
const [isHovered, setIsHovered] = useState<boolean>(false);
const isDarkMode = useIsDarkMode();
const onClickHandler = useCallback(() => {
if (!isLoading && buttonOnClick) {
buttonOnClick({ fieldData, fieldIndex });
}
}, [buttonOnClick, fieldData, fieldIndex, isLoading]);
const renderContent = useMemo(() => {
if (isLoading) {
return (
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>
);
}
if (isHovered) {
return (
<Popover content={<Typography>{iconHoverText}</Typography>}>
<Button
size="small"
type="text"
icon={buttonIcon}
onClick={onClickHandler}
/>
</Popover>
);
}
return null;
}, [buttonIcon, iconHoverText, isHovered, isLoading, onClickHandler]);
const onMouseHoverHandler = useCallback(
(value: boolean) => (): void => {
setIsHovered(value);
},
[],
);
return (
<Field
onMouseEnter={onMouseHoverHandler(true)}
onMouseLeave={onMouseHoverHandler(false)}
isDarkMode={isDarkMode}
>
<Typography style={ICON_STYLE.PLUS}>{name}</Typography>
{renderContent}
</Field>
);
}
interface FieldItemProps {
name: string;
buttonIcon: ReactNode;
buttonOnClick: (props: {
fieldData: IInterestingFields | ISelectedFields;
fieldIndex: number;
}) => void;
fieldData: IField;
fieldIndex: number;
isLoading: boolean;
iconHoverText: string;
}
export default FieldItem;

View File

@@ -1,11 +0,0 @@
import { blue, red } from '@ant-design/colors';
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
// Fields that can be filtered on but not grouped by in the log details view.
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
export const ICON_STYLE = {
PLUS: { color: blue[5] },
CLOSE: { color: red[5] },
};

View File

@@ -1,119 +0,0 @@
import { ChangeEvent, useCallback, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { CirclePlus, X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Col } from 'antd';
import CategoryHeading from 'components/Logs/CategoryHeading';
import { fieldSearchFilter } from 'lib/logs/fieldSearch';
import { AppState } from 'store/reducers';
import { ILogsReducer } from 'types/reducer/logs';
import { ICON_STYLE, RESTRICTED_SELECTED_FIELDS } from './config';
import FieldItem from './FieldItem';
import { CategoryContainer, FieldContainer } from './styles';
import { IHandleInterestProps, IHandleRemoveInterestProps } from './types';
import { onHandleAddInterest, onHandleRemoveInterest } from './utils';
function LogsFilters(): JSX.Element {
const {
fields: { interesting, selected },
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
const [selectedFieldLoading, setSelectedFieldLoading] = useState<number[]>([]);
const [interestingFieldLoading, setInterestingFieldLoading] = useState<
number[]
>([]);
const [filterValuesInput, setFilterValuesInput] = useState('');
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setFilterValuesInput((e.target as HTMLInputElement).value);
};
const onHandleAddSelectedToInteresting = useCallback(
({ fieldData, fieldIndex }: IHandleInterestProps) =>
(): Promise<void> =>
onHandleAddInterest({
fieldData,
fieldIndex,
interesting,
interestingFieldLoading,
setInterestingFieldLoading,
selected,
}),
[interesting, interestingFieldLoading, selected],
);
const onHandleRemoveSelected = useCallback(
({ fieldData, fieldIndex }: IHandleRemoveInterestProps) =>
(): Promise<void> =>
onHandleRemoveInterest({
fieldData,
fieldIndex,
interesting,
interestingFieldLoading,
selected,
setSelectedFieldLoading,
}),
[interesting, interestingFieldLoading, selected, setSelectedFieldLoading],
);
return (
<Col flex="250px">
<Input
placeholder="Filter Values"
onInput={handleSearch}
value={filterValuesInput}
onChange={handleSearch}
/>
<CategoryContainer>
<CategoryHeading>SELECTED FIELDS</CategoryHeading>
<FieldContainer>
{selected
.filter((field) => fieldSearchFilter(field.name, filterValuesInput))
.filter((field) => RESTRICTED_SELECTED_FIELDS.indexOf(field.name) === -1)
.map((field, idx) => (
<FieldItem
key={`${JSON.stringify(field)}`}
name={field.name}
fieldData={field}
fieldIndex={idx}
buttonIcon={<X style={ICON_STYLE.CLOSE} size="md" />}
buttonOnClick={onHandleRemoveSelected({
fieldData: field,
fieldIndex: idx,
})}
isLoading={selectedFieldLoading.includes(idx)}
iconHoverText="Remove from Selected Fields"
/>
))}
</FieldContainer>
</CategoryContainer>
<CategoryContainer>
<CategoryHeading>INTERESTING FIELDS</CategoryHeading>
<FieldContainer>
{interesting
.filter((field) => fieldSearchFilter(field.name, filterValuesInput))
.map((field, idx) => (
<FieldItem
key={`${JSON.stringify(field)}`}
name={field.name}
fieldData={field}
fieldIndex={idx}
buttonIcon={<CirclePlus style={ICON_STYLE.PLUS} size="md" />}
buttonOnClick={onHandleAddSelectedToInteresting({
fieldData: field,
fieldIndex: idx,
})}
isLoading={interestingFieldLoading.includes(idx)}
iconHoverText="Add to Selected Fields"
/>
))}
</FieldContainer>
</CategoryContainer>
</Col>
);
}
export default LogsFilters;

View File

@@ -1,29 +0,0 @@
import { blue, grey } from '@ant-design/colors';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
export const CategoryContainer = styled.div`
margin: 1rem 0;
padding-left: 0.2rem;
`;
export const FieldContainer = styled(Typography.Text)`
margin: 0.2rem 0;
color: ${blue[4]};
`;
export const Field = styled.div<{ isDarkMode: boolean }>`
border-radius: 0.5rem;
padding: 0.3rem 0.5rem;
height: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
background: ${({ isDarkMode }): string => (isDarkMode ? grey[7] : '#ddd')};
}
`;
export const ExtractField = styled(Typography.Text)`
color: ${blue[4]};
`;

View File

@@ -1,36 +0,0 @@
import { SetStateAction } from 'react';
import {
IField,
IInterestingFields,
ISelectedFields,
} from 'types/api/logs/fields';
type SetLoading = (value: SetStateAction<number[]>) => void;
export type IHandleInterestProps = {
fieldData: IInterestingFields;
fieldIndex: number;
};
export type IHandleRemoveInterestProps = {
fieldData: ISelectedFields;
fieldIndex: number;
};
export interface OnHandleAddInterestProps {
setInterestingFieldLoading: SetLoading;
fieldIndex: number;
fieldData: ISelectedFields;
interesting: IField[];
interestingFieldLoading: number[];
selected: IField[];
}
export interface OnHandleRemoveInterestProps {
setSelectedFieldLoading: SetLoading;
selected: IField[];
interesting: IField[];
interestingFieldLoading: number[];
fieldData: IInterestingFields;
fieldIndex: number;
}

View File

@@ -1,105 +0,0 @@
import { message } from 'antd';
import addToSelectedFields from 'api/logs/AddToSelectedField';
import removeSelectedField from 'api/logs/RemoveFromSelectedField';
import store from 'store';
import {
UPDATE_INTERESTING_FIELDS,
UPDATE_SELECTED_FIELDS,
} from 'types/actions/logs';
import { ErrorResponse } from 'types/api';
import { RESTRICTED_SELECTED_FIELDS } from './config';
import { OnHandleAddInterestProps, OnHandleRemoveInterestProps } from './types';
export const onHandleAddInterest = async ({
setInterestingFieldLoading,
fieldIndex,
fieldData,
interesting,
interestingFieldLoading,
selected,
}: OnHandleAddInterestProps): Promise<void> => {
const { dispatch } = store;
setInterestingFieldLoading((prevState: number[]) => {
prevState.push(fieldIndex);
return [...prevState];
});
try {
await addToSelectedFields({
...fieldData,
selected: true,
});
dispatch({
type: UPDATE_INTERESTING_FIELDS,
payload: {
field: interesting.filter((e) => e.name !== fieldData.name),
type: 'selected',
},
});
dispatch({
type: UPDATE_SELECTED_FIELDS,
payload: {
field: [...selected, fieldData],
type: 'selected',
},
});
} catch (errRes) {
message.error((errRes as ErrorResponse)?.error);
} finally {
setInterestingFieldLoading(
interestingFieldLoading.filter((e) => e !== fieldIndex),
);
}
};
export const onHandleRemoveInterest = async ({
setSelectedFieldLoading,
selected,
interesting,
interestingFieldLoading,
fieldData,
fieldIndex,
}: OnHandleRemoveInterestProps): Promise<void> => {
if (RESTRICTED_SELECTED_FIELDS.includes(fieldData.name)) {
return;
}
const { dispatch } = store;
setSelectedFieldLoading((prevState) => {
prevState.push(fieldIndex);
return [...prevState];
});
try {
await removeSelectedField({
...fieldData,
selected: false,
});
dispatch({
type: UPDATE_SELECTED_FIELDS,
payload: {
field: selected.filter((e) => e.name !== fieldData.name),
type: 'selected',
},
});
dispatch({
type: UPDATE_INTERESTING_FIELDS,
payload: {
field: [...interesting, fieldData],
type: 'interesting',
},
});
} catch (errRes) {
message.error((errRes as ErrorResponse)?.error);
} finally {
setSelectedFieldLoading(
interestingFieldLoading.filter((e) => e !== fieldIndex),
);
}
};

View File

@@ -1,27 +0,0 @@
import { Button, Row } from 'antd';
interface SearchFieldsActionBarProps {
applyUpdate: VoidFunction;
clearFilters: VoidFunction;
}
export function SearchFieldsActionBar({
applyUpdate,
clearFilters,
}: SearchFieldsActionBarProps): JSX.Element | null {
return (
<Row style={{ justifyContent: 'flex-end', paddingRight: '2.4rem' }}>
<Button
type="default"
onClick={clearFilters}
style={{ marginRight: '1rem' }}
>
Clear Filter
</Button>
<Button type="primary" onClick={applyUpdate}>
Apply
</Button>
</Row>
);
}
export default SearchFieldsActionBar;

View File

@@ -1,19 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
interface FieldKeyProps {
name: string;
type: string;
}
function FieldKey({ name, type }: FieldKeyProps): JSX.Element {
return (
<span style={{ margin: '0.25rem 0', display: 'flex', gap: '0.5rem' }}>
<Typography.Text>{name}</Typography.Text>
<Typography.Text color="muted" italic>
{type}
</Typography.Text>
</span>
);
}
export default FieldKey;

View File

@@ -1,259 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { SquareX, X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button, Select } from 'antd';
import CategoryHeading from 'components/Logs/CategoryHeading';
import {
ConditionalOperators,
QueryOperatorsMultiVal,
QueryOperatorsSingleVal,
} from 'lib/logql/tokens';
import { AppState } from 'store/reducers';
import { ILogsReducer } from 'types/reducer/logs';
import FieldKey from '../FieldKey';
import { QueryFieldContainer } from '../styles';
import { QueryFields } from '../utils';
import { Container, QueryWrapper } from './styles';
const { Option } = Select;
function QueryConditionField({
query,
queryIndex,
onUpdate,
}: QueryConditionFieldProps): JSX.Element {
const allOptions = Object.values(ConditionalOperators);
return (
<Select
defaultValue={
(query as QueryFields).value &&
(
(query as QueryFields)
?.value as unknown as QueryFields as unknown as string
).toUpperCase()
}
onChange={(e): void => {
onUpdate({ ...query, value: e }, queryIndex);
}}
>
{allOptions.map((cond) => (
<Option key={cond} value={cond} label={cond}>
{cond}
</Option>
))}
</Select>
);
}
interface QueryFieldProps {
query: Query;
queryIndex: number;
onUpdate: (query: Query, queryIndex: number) => void;
onDelete: (queryIndex: number) => void;
}
function QueryField({
query,
queryIndex,
onUpdate,
onDelete,
}: QueryFieldProps): JSX.Element | null {
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const {
fields: { selected },
} = useSelector<AppState, ILogsReducer>((store) => store.logs);
const getFieldType = useCallback(
(inputKey: string): string => {
const selectedField = selected.find((field) => inputKey === field.name);
if (selectedField) {
return selectedField.type;
}
return '';
},
[selected],
);
const fieldType = useMemo(
() => getFieldType(query[0].value as string),
[getFieldType, query],
);
const handleChange = (qIdx: number, value: string): void => {
const updatedQuery = [...query];
updatedQuery[qIdx].value = value || '';
if (qIdx === 1) {
if (Object.values(QueryOperatorsMultiVal).includes(value)) {
if (!Array.isArray(updatedQuery[2].value)) {
updatedQuery[2].value = [];
}
} else if (
Object.values(QueryOperatorsSingleVal).includes(value) &&
Array.isArray(updatedQuery[2].value)
) {
updatedQuery[2].value = '';
}
}
onUpdate(updatedQuery, queryIndex);
};
const handleClear = (): void => {
onDelete(queryIndex);
};
if (!Array.isArray(query)) {
return null;
}
return (
<QueryFieldContainer
style={{ ...(queryIndex === 0 && { gridColumnStart: 2 }) }}
>
<div style={{ flex: 1, minWidth: 100 }}>
<FieldKey name={(query[0] && query[0].value) as string} type={fieldType} />
</div>
<Select
defaultActiveFirstOption={false}
placeholder="Select Operator"
defaultValue={
query[1] && query[1].value
? (query[1].value as string).toUpperCase()
: null
}
onChange={(e): void => handleChange(1, e)}
style={{ minWidth: 150 }}
>
{Object.values({
...QueryOperatorsMultiVal,
...QueryOperatorsSingleVal,
}).map((cond) => (
<Option key={cond} value={cond} label={cond}>
{cond}
</Option>
))}
</Select>
<div style={{ flex: 2 }}>
{Array.isArray(query[2].value) ||
Object.values(QueryOperatorsMultiVal).some(
(op) => op.toUpperCase() === (query[1].value as string)?.toUpperCase(),
) ? (
<Select
mode="tags"
style={{ width: '100%' }}
open={isDropDownOpen}
onChange={(e): void => handleChange(2, e as never)}
defaultValue={(query[2] && query[2].value) || []}
notFoundContent={null}
onInputKeyDown={(): void => setIsDropDownOpen(true)}
onSelect={(): void => setIsDropDownOpen(false)}
/>
) : (
<Input
onChange={(e): void => {
handleChange(2, e.target.value);
}}
style={{ width: '100%' }}
defaultValue={query[2] && query[2].value}
value={query[2] && query[2].value}
/>
)}
</div>
<Button
icon={<X size="md" />}
type="text"
size="small"
onClick={handleClear}
/>
</QueryFieldContainer>
);
}
interface QueryConditionFieldProps {
query: QueryFields;
queryIndex: number;
onUpdate: (arg0: unknown, arg1: number) => void;
}
export type Query = { value: string | string[]; type: string }[];
export interface QueryBuilderProps {
keyPrefix: string;
onDropDownToggleHandler: (value: boolean) => VoidFunction;
fieldsQuery: QueryFields[][];
setFieldsQuery: (q: QueryFields[][]) => void;
syncKeyPrefix: () => void;
}
function QueryBuilder({
keyPrefix,
fieldsQuery,
setFieldsQuery,
onDropDownToggleHandler,
syncKeyPrefix,
}: QueryBuilderProps): JSX.Element {
const handleUpdate = (query: Query, queryIndex: number): void => {
const updated = [...fieldsQuery];
updated[queryIndex] = query as never; // parseQuery(query) as never;
setFieldsQuery(updated);
};
const handleDelete = (queryIndex: number): void => {
const updated = [...fieldsQuery];
if (queryIndex !== 0) {
updated.splice(queryIndex - 1, 2);
} else {
updated.splice(queryIndex, 2);
}
setFieldsQuery(updated);
// initiate re-render query panel
syncKeyPrefix();
};
const QueryUI = (
fieldsQuery: QueryFields[][],
): JSX.Element | JSX.Element[] => {
const result: JSX.Element[] = [];
fieldsQuery.forEach((query, idx) => {
if (Array.isArray(query) && query.length > 1) {
result.push(
<QueryField
key={keyPrefix}
query={query}
queryIndex={idx}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>,
);
} else {
result.push(
<div key={keyPrefix}>
<QueryConditionField
query={Array.isArray(query) ? query[0] : query}
queryIndex={idx}
onUpdate={handleUpdate as never}
/>
</div>,
);
}
});
return result;
};
return (
<>
<Container isMargin={fieldsQuery.length === 0}>
<CategoryHeading>LOG QUERY BUILDER</CategoryHeading>
<SquareX onClick={onDropDownToggleHandler(false)} size="md" />
</Container>
<QueryWrapper key={keyPrefix}>{QueryUI(fieldsQuery)}</QueryWrapper>
</>
);
}
export default QueryBuilder;

View File

@@ -1,17 +0,0 @@
import styled from 'styled-components';
interface Props {
isMargin: boolean;
}
export const Container = styled.div<Props>`
display: flex;
justify-content: space-between;
width: 100%;
margin-bottom: ${(props): string => (props.isMargin ? '2rem' : '0')};
`;
export const QueryWrapper = styled.div`
display: grid;
grid-template-columns: 80px 1fr;
margin: 0.5rem 0px;
`;

View File

@@ -1,62 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Button } from 'antd';
import CategoryHeading from 'components/Logs/CategoryHeading';
import map from 'lodash-es/map';
import { AppState } from 'store/reducers';
// import { ADD_SEARCH_FIELD_QUERY_STRING } from 'types/actions/logs';
import { ILogsReducer } from 'types/reducer/logs';
import FieldKey from './FieldKey';
interface SuggestedItemProps {
name: string;
type: string;
applySuggestion: (name: string) => void;
}
function SuggestedItem({
name,
type,
applySuggestion,
}: SuggestedItemProps): JSX.Element {
const addSuggestedField = (): void => {
applySuggestion(name);
};
return (
<Button
type="text"
style={{ display: 'block', padding: '0.2rem' }}
onClick={addSuggestedField}
>
<FieldKey name={name} type={type} />
</Button>
);
}
interface SuggestionsProps {
applySuggestion: (name: string) => void;
}
function Suggestions({ applySuggestion }: SuggestionsProps): JSX.Element {
const {
fields: { selected },
} = useSelector<AppState, ILogsReducer>((store) => store.logs);
return (
<div>
<CategoryHeading>SUGGESTIONS</CategoryHeading>
<div>
{map(selected, (field) => (
<SuggestedItem
key={JSON.stringify(field)}
name={field.name}
type={field.type}
applySuggestion={applySuggestion}
/>
))}
</div>
</div>
);
}
export default Suggestions;

View File

@@ -1,123 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useNotifications } from 'hooks/useNotifications';
import { reverseParser } from 'lib/logql';
import { flatten } from 'lodash-es';
import { AppState } from 'store/reducers';
import { ILogsReducer } from 'types/reducer/logs';
import { SearchFieldsActionBar } from './ActionBar';
import QueryBuilder from './QueryBuilder/QueryBuilder';
import Suggestions from './Suggestions';
import {
createParsedQueryStructure,
fieldsQueryIsvalid,
hashCode,
initQueryKOVPair,
prepareConditionOperator,
QueryFields,
} from './utils';
export interface SearchFieldsProps {
onDropDownToggleHandler: (value: boolean) => VoidFunction;
updateQueryString: (value: string) => void;
}
function SearchFields({
onDropDownToggleHandler,
updateQueryString,
}: SearchFieldsProps): JSX.Element {
const {
searchFilter: { parsedQuery },
} = useSelector<AppState, ILogsReducer>((store) => store.logs);
const [fieldsQuery, setFieldsQuery] = useState(
createParsedQueryStructure([...parsedQuery] as never[]),
);
const keyPrefixRef = useRef(hashCode(JSON.stringify(fieldsQuery)));
const { notifications } = useNotifications();
useEffect(() => {
const updatedFieldsQuery = createParsedQueryStructure([
...parsedQuery,
] as never[]);
setFieldsQuery(updatedFieldsQuery);
const incomingHashCode = hashCode(JSON.stringify(updatedFieldsQuery));
if (incomingHashCode !== keyPrefixRef.current) {
keyPrefixRef.current = incomingHashCode;
}
}, [parsedQuery]);
// syncKeyPrefix initiates re-render. useful in situations like
// delete field (in search panel). this method allows condiitonally
// setting keyPrefix as doing it on every update of query initiates
// a re-render. this is a problem for text fields where input focus goes away.
const syncKeyPrefix = (): void => {
keyPrefixRef.current = hashCode(JSON.stringify(fieldsQuery));
};
const addSuggestedField = useCallback(
(name: string): void => {
if (!name) {
return;
}
const query = [...fieldsQuery];
if (fieldsQuery.length > 0) {
query.push([prepareConditionOperator()]);
}
const newField: QueryFields[] = [];
initQueryKOVPair(name).forEach((q) => newField.push(q));
query.push(newField);
keyPrefixRef.current = hashCode(JSON.stringify(query));
setFieldsQuery(query);
},
[fieldsQuery, setFieldsQuery],
);
const applyUpdate = useCallback((): void => {
const flatParsedQuery = flatten(fieldsQuery);
if (!fieldsQueryIsvalid(flatParsedQuery)) {
notifications.error({
message: 'Please enter a valid criteria for each of the selected fields',
});
return;
}
keyPrefixRef.current = hashCode(JSON.stringify(flatParsedQuery));
updateQueryString(reverseParser(flatParsedQuery));
onDropDownToggleHandler(false)();
}, [fieldsQuery, notifications, onDropDownToggleHandler, updateQueryString]);
const clearFilters = useCallback((): void => {
keyPrefixRef.current = hashCode(JSON.stringify([]));
setFieldsQuery([]);
updateQueryString('');
}, [updateQueryString]);
return (
<>
<QueryBuilder
key={keyPrefixRef.current}
keyPrefix={keyPrefixRef.current}
onDropDownToggleHandler={onDropDownToggleHandler}
fieldsQuery={fieldsQuery}
setFieldsQuery={setFieldsQuery}
syncKeyPrefix={syncKeyPrefix}
/>
<SearchFieldsActionBar
applyUpdate={applyUpdate}
clearFilters={clearFilters}
/>
<Suggestions applySuggestion={addSuggestedField} />
</>
);
}
export default SearchFields;

View File

@@ -1,16 +0,0 @@
import { blue } from '@ant-design/colors';
import styled from 'styled-components';
export const QueryFieldContainer = styled.div`
padding: 0.25rem 0.5rem;
margin: 0.1rem 0.5rem 0;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 0.25rem;
gap: 1rem;
width: 100%;
&:hover {
background: ${blue[6]};
}
`;

View File

@@ -1,137 +0,0 @@
// @ts-nocheck
import {
ConditionalOperators,
QueryTypes,
ValidTypeSequence,
ValidTypeValue,
} from 'lib/logql/tokens';
export interface QueryFields {
type: keyof typeof QueryTypes;
value: string | string[];
}
export function fieldsQueryIsvalid(queryFields: QueryFields[]): boolean {
let lastOp: string;
let result = true;
queryFields.forEach((q, idx) => {
if (!q.value || q.value === null || q.value === '') {
result = false;
}
if (Array.isArray(q.value) && q.value.length === 0) {
result = false;
}
const nextOp = idx < queryFields.length ? queryFields[idx + 1] : undefined;
if (!ValidTypeSequence(lastOp?.type, q?.type, nextOp?.type)) {
result = false;
}
if (!ValidTypeValue(lastOp?.value, q.value)) {
result = false;
}
lastOp = q;
});
return result;
}
export const queryKOVPair = (): QueryFields[] => [
{
type: QueryTypes.QUERY_KEY,
value: null,
},
{
type: QueryTypes.QUERY_OPERATOR,
value: null,
},
{
type: QueryTypes.QUERY_VALUE,
value: null,
},
];
export const initQueryKOVPair = (
name: string = null,
op: string = null,
value: string | string[] = null,
): QueryFields[] => [
{
type: QueryTypes.QUERY_KEY,
value: name,
},
{
type: QueryTypes.QUERY_OPERATOR,
value: op,
},
{
type: QueryTypes.QUERY_VALUE,
value: value,
},
];
export const prepareConditionOperator = (
op: string = ConditionalOperators.AND,
): QueryFields => {
return {
type: QueryTypes.CONDITIONAL_OPERATOR,
value: op,
};
};
export const createParsedQueryStructure = (
parsedQuery = [],
): QueryFields[][] => {
if (parsedQuery.length === 0) {
return parsedQuery;
}
const structuredArray = [queryKOVPair()];
let cond;
let qCtr = -1;
parsedQuery.forEach((query) => {
if (cond) {
structuredArray.push(cond);
structuredArray.push(queryKOVPair());
cond = null;
qCtr = -1;
}
const stagingArr = structuredArray.at(-1);
const prevQuery =
Array.isArray(stagingArr) && qCtr >= 0 ? stagingArr[qCtr] : null;
if (query.type === QueryTypes.QUERY_KEY) {
stagingArr[qCtr + 1] = query;
} else if (
query.type === QueryTypes.QUERY_OPERATOR &&
prevQuery &&
prevQuery.type === QueryTypes.QUERY_KEY
) {
stagingArr[qCtr + 1] = query;
} else if (
query.type === QueryTypes.QUERY_VALUE &&
prevQuery &&
prevQuery.type === QueryTypes.QUERY_OPERATOR
) {
stagingArr[qCtr + 1] = query;
} else if (query.type === QueryTypes.CONDITIONAL_OPERATOR) {
cond = query;
}
qCtr++;
});
return structuredArray;
};
export const hashCode = (s: string): string => {
if (!s) {
return '0';
}
return `${Math.abs(
[...s].reduce((a, b) => {
a = (a << 5) - a + b.codePointAt(0);
return a & a;
}, 0),
)}`;
};

View File

@@ -1,230 +0,0 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect, useDispatch, useSelector } from 'react-redux';
import { Input, InputRef, Popover } from 'antd';
import useUrlQuery from 'hooks/useUrlQuery';
import getStep from 'lib/getStep';
import debounce from 'lodash-es/debounce';
import { getIdConditions } from 'pages/Logs/utils';
// eslint-disable-next-line no-restricted-imports
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { GetLogsFields } from 'store/actions/logs/getFields';
import { getLogs } from 'store/actions/logs/getLogs';
import { getLogsAggregate } from 'store/actions/logs/getLogsAggregate';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
FLUSH_LOGS,
SET_LOADING,
SET_LOADING_AGGREGATE,
TOGGLE_LIVE_TAIL,
} from 'types/actions/logs';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ILogsReducer } from 'types/reducer/logs';
import { popupContainer } from 'utils/selectPopupContainer';
import SearchFields from './SearchFields';
import { Container, DropDownContainer } from './styles';
import { useSearchParser } from './useSearchParser';
function SearchFilter({
getLogs,
getLogsAggregate,
getLogsFields,
}: SearchFilterProps): JSX.Element {
const { updateQueryString, queryString } = useSearchParser();
const [searchText, setSearchText] = useState(queryString);
const [showDropDown, setShowDropDown] = useState(false);
const searchRef = useRef<InputRef>(null);
const { logLinesPerPage, idEnd, idStart, liveTail, order } = useSelector<
AppState,
ILogsReducer
>((state) => state.logs);
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const dispatch = useDispatch<Dispatch<AppActions>>();
// keep sync with url queryString
useEffect(() => {
setSearchText(queryString);
}, [queryString]);
const debouncedupdateQueryString = useMemo(
() => debounce(updateQueryString, 300),
[updateQueryString],
);
const onDropDownToggleHandler = useCallback(
(value: boolean) => (): void => {
setShowDropDown(value);
},
[],
);
const handleSearch = useCallback(
(customQuery: string) => {
getLogsFields();
const { maxTime, minTime } = globalTime;
if (liveTail === 'PLAYING') {
dispatch({
type: TOGGLE_LIVE_TAIL,
payload: 'PAUSED',
});
dispatch({
type: FLUSH_LOGS,
});
dispatch({
type: TOGGLE_LIVE_TAIL,
payload: liveTail,
});
dispatch({
type: SET_LOADING,
payload: false,
});
getLogsAggregate({
timestampStart: minTime,
timestampEnd: maxTime,
step: getStep({
start: minTime,
end: maxTime,
inputFormat: 'ns',
}),
q: customQuery,
...(idStart ? { idGt: idStart } : {}),
...(idEnd ? { idLt: idEnd } : {}),
});
} else {
getLogs({
q: customQuery,
limit: logLinesPerPage,
orderBy: 'timestamp',
order,
timestampStart: minTime,
timestampEnd: maxTime,
...getIdConditions(idStart, idEnd, order),
});
getLogsAggregate({
timestampStart: minTime,
timestampEnd: maxTime,
step: getStep({
start: minTime,
end: maxTime,
inputFormat: 'ns',
}),
q: customQuery,
});
}
},
[
dispatch,
getLogs,
getLogsAggregate,
idEnd,
idStart,
liveTail,
logLinesPerPage,
globalTime,
getLogsFields,
order,
],
);
const urlQuery = useUrlQuery();
const urlQueryString = urlQuery.get('q');
useEffect(() => {
dispatch({
type: SET_LOADING,
payload: true,
});
dispatch({
type: SET_LOADING_AGGREGATE,
payload: true,
});
const debouncedHandleSearch = debounce(handleSearch, 600);
debouncedHandleSearch(urlQueryString || '');
return (): void => {
debouncedHandleSearch.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
urlQueryString,
idEnd,
idStart,
logLinesPerPage,
dispatch,
globalTime.maxTime,
globalTime.minTime,
order,
]);
const onPopOverChange = useCallback(
(isVisible: boolean) => {
onDropDownToggleHandler(isVisible)();
},
[onDropDownToggleHandler],
);
return (
<Container>
<Popover
getPopupContainer={popupContainer}
placement="bottom"
content={
<DropDownContainer>
<SearchFields
updateQueryString={updateQueryString}
onDropDownToggleHandler={onDropDownToggleHandler}
/>
</DropDownContainer>
}
trigger="click"
overlayInnerStyle={{
width: `${searchRef?.current?.input?.offsetWidth || 0}px`,
}}
open={showDropDown}
destroyTooltipOnHide
onOpenChange={onPopOverChange}
>
<Input.Search
ref={searchRef}
placeholder="Search Filter"
value={searchText}
onChange={(e): void => {
const { value } = e.target;
setSearchText(value);
}}
onSearch={debouncedupdateQueryString}
allowClear
/>
</Popover>
</Container>
);
}
interface DispatchProps {
getLogs: typeof getLogs;
getLogsAggregate: typeof getLogsAggregate;
getLogsFields: typeof GetLogsFields;
}
type SearchFilterProps = DispatchProps;
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
getLogs: bindActionCreators(getLogs, dispatch),
getLogsAggregate: bindActionCreators(getLogsAggregate, dispatch),
getLogsFields: bindActionCreators(GetLogsFields, dispatch),
});
export default connect(null, mapDispatchToProps)(memo(SearchFilter));

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