Compare commits

..

19 Commits

Author SHA1 Message Date
Abhi Kumar
98d2da9279 feat(dashboards-v2): report panelKind on panel analytics events
Panel events identified the panel only by its legacy panel type, which
cannot tell apart two kinds that map onto the same one — so a newly added
kind is indistinguishable from the kind it shares a type with.

Adds panelKind alongside the existing panelType on all seven events (no
data, clone, delete, move, CSV export, drilldown opened, create alert).
Additive on purpose: existing reports keep resolving.

Assisted-by: Claude Opus 5
2026-08-14 14:51:00 +05:30
Abhi Kumar
308e63444c refactor(dashboards-v2): drive the query path and builder mode off the declarations
buildQueryRangeRequest now takes the kind's declared query capabilities
instead of a legacy panel type, so the request type, table formatting, bar
step interval and list order tiebreaker all come from the kind itself. The
editor asks the same declarations whether the query builder runs in
list-view mode, offers a trace operator, shows the plot-mode chip, or seeds
a default query, rather than testing "is this the List panel?" in four
places.

The capabilities are passed in rather than looked up by kind: the panel
registry carries every renderer with it, which has no business in the data
path — importing it there pulls the app's API client into any test that
touches the request builder. The call sites already resolve the definition,
so threading it costs nothing. PlotTag takes isListView instead of a panel
type, so a presentational component no longer needs the enum at all.

panelTypeToRequestType moves to persesQueryAdapters, the V1 Query pivot
that is now its only caller — the legacy switch belongs on the V1 side of
the boundary rather than in the middle of the V5 request builder. The
shared QueryBuilderV2 provider keeps its legacy panelType prop: that is
state inside the shared provider, read by its subcomponents, and out of
scope here.

Assisted-by: Claude Opus 5
2026-08-14 14:50:28 +05:30
Abhi Kumar
7766fb2a2c refactor(dashboards-v2): declare per-kind query capabilities
Each panel kind now states how its query behaves — request type, result
formatting, step-interval and order treatment, paging, whether it is
authored as a list view, and whether it offers a trace operator.

These are the questions V2 answered by comparing against the legacy
PANEL_TYPES enum. Declaring them per kind means the compiler asks for an
answer when a kind is added, instead of the kind silently falling through
someone else's switch. The expectations are an exhaustive Record over
PanelKind, so a new kind cannot ship without stating its request shape.

getPanelDefinition also stops lying. It was typed to return a definition
for any PanelKind, but the registry only holds the kinds this build
registers — a dashboard spec written by a newer SigNoz names one it has
never heard of, and callers coped by truthiness-checking a value the type
said could not be falsy. An unregistered kind now resolves to
UNSUPPORTED_PANEL, which declares nothing and renders as unsupported, so
callers read a definition's fields directly and such a panel says why it is
blank instead of leaving a hole in the layout. Whether a kind can be
rendered at all becomes its own question: isPanelKindSupported.

Assisted-by: Claude Opus 5
2026-08-14 14:49:40 +05:30
Abhi Kumar
697806937b refactor(charts): declare the time axis instead of inferring it from a panel type
The uPlotV2 axis builder decided X-axis date formatting by testing the
panel type against a hardcoded [TIME_SERIES, BAR] list. A chart that plots
time but is not one of those two silently lost its time-formatted ticks —
no type error, no failing test, just wrong-looking ticks.

Axis props now take isTimeAxis and each caller states it: the three V2
kinds through the shared base config (histogram passes false — its X axis
is buckets), and the Meter Explorer, K8s metrics and V1 shared config
builders directly.

Assisted-by: Claude Opus 5
2026-08-14 14:49:11 +05:30
Abhi Kumar
0c87c10ff5 chore(dashboards-v2): remove the unused ViewPanelQueryBuilder
The View modal renders PanelEditorQueryBuilder; this component had no
importers and referenced a stylesheet class that no longer exists.

Assisted-by: Claude Opus 5
2026-08-14 14:48:52 +05:30
Vinicius Lourenço
013e631c68 fix(translate): do not crash on use google translate (#12466)
<!--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

This PR aims to fix the issues we have with translation, today when we
enable the translation, some parts of the app crash due to how the react
works and how the translation works.

In simple works, if you have: `<button>{(var ? 'text' : 'other text')}
{icon}` it will crash because the React will represent the `text` and
`other text` as `TextNode`, and when translate is performed, it changes
the parent of this element to `font` and causes the react to be "blind",
and when trying to delete the element, it cannot find.

> Read
https://martijnhols.nl/blog/everything-about-google-translate-crashing-react
to understand more

There's many fixes that includes ignore the errors and let the app with
invalid data, or actually go ahead and find the places with this pattern
and avoid them.

I kinda mixed two approaches, I introduced a new plugin based on
https://github.com/getcouped/eslint-plugin-react-google-translate/ but
adapted a little bit for our necessity and for our codebase (with oxc).
If we only use this plugin to find and fix the places, we will find most
of the issues crashing the app, but not all of them.

Why not all? Because even our component library is not safe enough for
google translate, eg: https://github.com/SigNoz/components/issues/351

So, I also included https://npmx.dev/package/translation-resilience,
this lib has another approach to fix the issue with the TextNode:

```
Instead of swallowing errors, this shim puts the original text nodes **back** the moment the renderer touches them:

1. A document-wide `MutationObserver` recognizes translation's displacement pattern (merge, wrap, remove — a pattern renderer commits never produce) and tracks each replaced text run as a *displacement group*: the ordered renderer-owned originals with their pre-translation values, plus the wrapper nodes currently standing in for them.
2. Patched `Node.prototype.removeChild` / `insertBefore` / `appendChild` and the `nodeValue` / `data` setters detect operations on displaced text nodes and first **restore the group** — originals go back into the wrappers' position, wrappers are removed — then let the native operation proceed on a consistent tree.
3. The translator's own observer notices the restored (now updated) text and re-translates it, so the user sees fresh, translated content. The loop is self-healing: update → restore → re-translate.

The result: no crashes, **and** live data keeps updating on translated pages — in the visitor's language.
```

We could keep the lib only and no plugin? Yes, but I want to make our
app more resilient without need the help of the lib, so we can continue
to adopt/fix places that has the pattern to crash the app, and
eventually, we can remove the lib because our app is resilient enough.

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

Closes https://github.com/SigNoz/platform-pod/issues/2912

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/0225464b-1afe-46ad-afe7-25f79e25201a

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

This lib has a performance cost but the lib only enable itself when it
detects the translation is enabled, so our app (and users) should not
see/perceive any performance cost due to this lib. But again, this is
another reason to slowly adapt and fix all places that offers a
potential problem to google translate.
2026-08-13 08:23:47 +00:00
Abhi kumar
535a29adbf fix(query-builder): let tag add-on fields grow instead of spilling their tags (#12541)
<!--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

- Adding a few values to **Group By** made the tags wrap onto a second
row that rendered outside the field, on top of the add-on toggles below
it. **Order By** and the formula Order By row had the same bug.
- The add-on field pinned the antd select and its selector to `height:
36px`, so it could never grow. Both are `min-height: 36px` now —
single-line selects keep the same 36px row, tag selects grow with their
rows.

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before
<img width="1742" height="117" alt="image"
src="https://github.com/user-attachments/assets/12dfccbe-d087-4857-9bd0-3b6dfa0a1da1"
/>

After
<img width="1778" height="164" alt="image"
src="https://github.com/user-attachments/assets/abeb0b38-b0ce-4749-bf1c-32fc865833b9"
/>

#### Issues Closed
Closes https://github.com/SigNoz/pulse-pod/issues/270


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Broke in #11992, which put `height: 36px` on `.ant-select-selector`
and moved the field's border onto it. The same `height` on the root
`.ant-select` predates that but never applied, because
`GroupByFilter`/`OrderByFilter` pass an inline `height: 100%` — antd was
left to size the selector from its content, so the field used to grow as
tags wrapped.
- Checked in a headless-Chromium repro of the field using antd 5.11's
select rules: 12 tags in a ~900px field hang 20px below the box on
`main`, and sit inside it with this change.
2026-08-13 08:16:25 +00:00
Tushar Vats
3d5aab744d fix(logs): stringify body_v2 in the v3 logs select (#12534)
#### Description

Two commits: a clean revert of #12523, then a reland with the body
stringified.

**Why the revert.** #12523 selected `body_v2 as body` for orgs on JSON
bodies. ClickHouse resolves identifiers in `WHERE` against SELECT
aliases, and the v3 filter builder emits a bare `body` (`body != ''` for
exists, `lower(body) like …` for contains), so every body filter started
running against the JSON column and failed with `Code: 117 … Cannot
parse JSON object here: while converting '' to JSON`. The pipelines
preview always sends the pipeline's filter, so picking sample logs by
body errored outright.

**What the reland changes.** The select is `toString(body_v2) as body`,
so the alias stays a String and those filters compare against the body
text again. As a bonus they now actually match — before #12523 they ran
against the legacy `body` column, which the collector writes empty for
these orgs, so they silently matched nothing. The JSON column decoding
#12523 added to `GetListResultV3` is not relanded: nothing selects a
JSON column on this path now, and it failed the entire query on a row it
could not unmarshal rather than just that row.

Reproduced directly against ClickHouse:

```sql
SELECT body_v2 AS body FROM signoz_logs.distributed_logs_v2 WHERE body != '' LIMIT 1;
-- Code: 117. DB::Exception: Cannot parse JSON object here: while converting '' to JSON(...)

SELECT toString(body_v2) AS body FROM signoz_logs.distributed_logs_v2 WHERE body != '' LIMIT 1;
-- {"level":"error","message":"json log line","user":"alice"}
```

#### Additional Information

Verified end to end on a local stack (devenv ClickHouse + a collector
with `body_json_enabled`, `use_json_body` on): `body EXISTS` and `body
CONTAINS` both return rows, and the body comes back as the stringified
JSON.

v5 is unaffected — its field mapper builds a real JSON expression
instead of emitting a bare `body`, so `body EXISTS` there already worked
and still returns object bodies.

The response shape for v3 is a JSON string rather than the object #12523
returned. Consumers that need structure can parse it; the pipelines
preview endpoint accepts either, since it types the log body as `any`
and re-parses through the `normalize` pipeline.

Known gaps left alone, since they predate this or need the filter
builder to become JSON-aware: aggregation and group-by queries still
read the empty legacy `body` column (only the list select carries the
alias), body filters cannot use the `body_v2` skip indexes while
stringified, and the v4 endpoint never sets the flag.
2026-08-13 08:04:42 +00:00
Gaurav Tewari
a8309a1a02 test(qb): add integration tests for recent searches dropdown (#12045)
#### Description

Adds integration tests for the Recent Searches dropdown in the query
builder search editor.

What's covered:

- A saved recent shows up under "Recent searches" on focus
- Recents filter by substring as you type
- Recents stay partitioned by signal — a `traces` recent never leaks
into the `logs` editor
- A recent identical to what's already typed is excluded
- Clicking a recent applies the whole expression and closes the popup
- The dropdown caps at `RECENTS_DISPLAY_CAP` entries, newest first
(asserted as a full ordered array)
- The per-entry delete button removes the recent from both the dropdown
and the store, without applying it

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5649

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-13 08:04:03 +00:00
Aditya Singh
897036968c feat(trace-details): add analytics events to span percentile flow (#12540)
<!--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
Instrument the span percentile widget with product analytics: 

- panel toggle
- time-range change
- resource-attributes selector toggle
- attribute selection change. 

Events go through the existing useTraceDetailLogEvent hook so view and
traceId are injected.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5908
2026-08-13 07:56:36 +00:00
Vinicius Lourenço
55ef5fbc3c fix(infrastructure-monitoring-namespaces): wrong division for available/desired and use lastest instead of avg (#12429)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / prepare (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
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

This follows the same pattern as
https://github.com/SigNoz/signoz/pull/11681 to use `latest` instead of
`avg`, and also fixes the calculation of `util %` that was suppose to be
`desired/available * 100` instead of current value `available/desired`.

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

Before:

<img width="857" height="364" alt="image"
src="https://github.com/user-attachments/assets/949db1a8-c27d-41da-8573-398a4d53af24"
/>

After:

<img width="851" height="336" alt="image"
src="https://github.com/user-attachments/assets/5181849a-28e4-45f8-b7a5-0d611b5ae02e"
/>

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

Closes https://github.com/SigNoz/pulse-pod/issues/210

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [x] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: No
- Manual verification: Yes
- Edge cases covered: -

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Infrastructure Monitoring - Namespaces
- Potential regressions: None
- Rollback plan: Revert this commit

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | We updated the table for Desired (pods) inside the
Namespace Details on Infrastructure Monitoring to correctly show the
`util %`. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-13 05:52:36 +00:00
Shivam Gupta
49749626dc fix(onboarding): list multi-signal data sources under every signal they support (#12522)
#### Description

- Some data sources ship a single doc that sets up two or three signals,
but carried only one tag, so they showed up in exactly one section of
the picker. Searching `temporal` surfaced it only under APM/Traces even
though both Temporal docs configure traces, metrics and logs.
- Tagged them with every signal their doc actually configures, so they
list under each matching section — the same way `Deno` already does. No
UI changes needed: `groupDataSourcesByTags` already fans an entry out
across its tags.

| entry | was | now |
| --- | --- | --- |
| Temporal | `apm/traces` | `apm/traces`, `logs`, `metrics` |
| Nginx - OpenTelemetry (was "Nginx - Tracing") | `apm/traces` |
`apm/traces`, `logs`, `metrics` |
| OpenTelemetry eBPF (OBI) | `apm/traces` | `apm/traces`, `metrics` |
| DBOS | `apm/traces` | `apm/traces`, `logs` |
| Cloudflare Workers | `apm/traces` | `apm/traces`, `logs` |

- "Nginx - Tracing" is renamed to "Nginx - OpenTelemetry" since it no
longer lists only under traces, and to stay distinct from the existing
built-in Nginx integration entry.

#### Additional Information

- All 82 docs behind the 70 single-signal-tagged entries were read to
decide this; the other 77 are genuinely single-signal. Every language
APM doc explicitly sets `OTEL_METRICS_EXPORTER=none` /
`OTEL_LOGS_EXPORTER=none`, and the matching metrics docs set
`OTEL_TRACES_EXPORTER=none` — so splits like `Java` / `Java logs` /
`Java Metrics` are correct as they stand.
- Left unchanged, but worth a second opinion: the logs docs for Java,
Python, Node.js (Pino/Winston/Bunyan) and Golang (Logrus/Zerolog) run
auto-instrumentation that emits traces, but only ever mention traces to
tell you how to switch them off. Read as logs-only here.
2026-08-13 04:03:15 +00:00
Pandey
061eb1f867 chore(deps): bump clickhouse-sql-parser to v0.5.6 (#12536)
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

Bumps `github.com/AfterShip/clickhouse-sql-parser` from v0.5.5 to
v0.5.6.

- v0.5.6 parses a parenthesized left operand of a set operator (upstream
https://github.com/AfterShip/clickhouse-sql-parser/pull/312), e.g.
`(SELECT 1) UNION ALL (SELECT 2)`.
- Moves the three now-passing parenthesized set-operation cases into the
pass table in `clickhouse_sql_test.go` as regression canaries.
- Records the outstanding `NULLS FIRST|LAST` ORDER BY gap in the
known-gap table — the parser still rejects it, so it stays tracked until
fixed upstream.
2026-08-12 19:25:17 +00:00
Tushar Vats
092e0b7d99 refactor(qb): build IN as an OR of equalities (#12504)
#### Description

- `IN` and `NOT IN` now route each value back through the condition
builder with `=` / `!=` instead of assembling the comparisons a second
time, so whatever a builder does for a scalar comparison applies to the
list form too. Applied to logs, traces and audit — the three that
already fanned a list out into per-value comparisons.
- Fixes `body.<path>[*] IN [...]` with `use_json_body` off returning a
**500**. The list shape made the path extract as `Array(String)`, and
ClickHouse refuses to compare an array to a scalar (code 130);
extracting per value reads the field instead. The new case in
`querierlogs/06_json_body.py` fails on `main` and passes here — verified
both ways against a real ClickHouse.

#### Additional Information

- **resourcefilter is deliberately left out**: it asserts the key index
filter (`labels LIKE '%key%'`) once for the whole list, alongside one
value filter per value. A recursed arm derives its own key filter, so
the same predicate would be repeated per value — `(e1 AND kIdx AND l1)
OR (e2 AND kIdx AND l2)` instead of `(e1 OR e2) AND kIdx AND (l1 OR
l2)`. Same rows either way, but no reason to emit the duplicate.
- **telemetrymetadata is deliberately left out**: it applies a
key-existence guard at a single exit, so a recursed arm comes back
already wrapped and the guard would either nest or the case would have
to skip the shared tail — losing the invariant that every condition is
guarded.
- **metrics and rulestatehistory** build a real `sb.In`; there is no
per-value fan-out to delegate to.
- Mixed-type lists are safe: `DataTypeCollisionHandledFieldName`
normalises the whole list before the loop, so `IN ('200', 5)` emits
identical SQL before and after.
- Base of a stack — the follow-ups add index-friendly predicates to `=`,
which `IN` then picks up.
2026-08-12 15:36:25 +00:00
Tushar Vats
16849967c5 feat(frontend): register search() as a query builder function (#12513)
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

Stacked on SigNoz/signoz#12491 — review that one first.

#12491 makes the frontend parser lex and parse `search()`. This makes
the editor act on it. Scoped to `search('x')` and `search(x)` for now —
scope arguments are not handled yet.

- **Function suggestion.** The cursor on `search` resolved to no
context, so the suggestion list never opened and `search()` was never
offered as a completion. Registered alongside the `has` family, in the
same two places `hasToken` needed.
- **The term is free text, not a key.** `search(x)` lexes its term as a
key, so the editor offered attribute keys inside the call and would
complete one into the term — and pair extraction turned it into a filter
item keyed `x`, which the log detail drawer rebuilds into a real filter.
Key and value suggestions are now suppressed inside a `search()` call,
and its argument no longer produces a pair. `has(key, value)` does take
a real key, so its suggestions are untouched.
- **Logs only.** `FilterOperatorSearch` is implemented in
`logstelemetryschema` alone; traces and metrics reject it as an
unsupported operator, so the suggestion is gated to the logs signal.
- **Recents.** `SEARCH(...)` and `search(...)` no longer dedup as two
distinct recent queries.

#### Additional Information

`search` is a reserved word now, so `search = 'x'` and `search exists`
no longer parse — the same tradeoff `has`/`hasAny` already carry,
matching the backend grammar.

Three things deliberately left out:

- After picking any function from the autocomplete the cursor lands
outside the brackets, so you have to arrow back before typing the
argument. Long-standing behaviour across the whole `has` family, but
`search()` is the case where an empty call is always a syntax error.
Filed as SigNoz/engineering-pod#5893.
- `has(key, value)` still contributes a phantom filter item keyed on its
first argument, which reaches the trace waterfall's API query, the
metrics drilldown and the infra filter telemetry. Fixing it needs the
autocomplete to keep reading that argument as a key, so it is not a
one-liner.
- Scope arguments (`search('err', body)`) parse but are not supported
here.

`isCursorInSearchTerm` runs on every cursor move, so it text-matches
`search` before paying for a lex. A `SEARCH` token only exists where the
lexer matched exactly those six letters — a word character on either
side would have produced a `KEY` — so the pre-check cannot produce a
false negative. `body = 'search this'` is covered by a test, since only
the lexer can tell that one is a quoted value.

Unrelated to this PR: `QuerySearch.test.tsx › fetches key suggestions on
mount for LOGS` is flaky on `main` too. An earlier test in that file
types `http.` and never unmounts, so its debounced `getKeySuggestions`
resolves after this test's `mockClear()` and wins the `mock.calls[length
- 1]` read.
2026-08-12 12:49:25 +00:00
Vikrant Gupta
c8e7685f06 test(integration): drop the deprecated user endpoints from the suite (#12529)
#### Description

- The integration suite was the last consumer of the five deprecated v1
user endpoints. It now provisions through `POST /api/v2/users`, `PUT
/api/v2/users/{id}/reset_password_tokens` and `POST
/api/v2/factor_password/reset`, so those routes can be deleted once the
remaining upstream consumer is deployed.
- Role assignment moves off the deprecated `POST
/api/v2/users/{id}/roles` and `DELETE /api/v2/users/{id}/roles/{roleId}`
onto `/api/v2/user_roles`. Removal is keyed by the `user_role` entry id,
so the tests read it from `GET /api/v2/users/{id}`. `GET
/api/v2/users/{id}/roles` is not deprecated and stays.
- `create_active_user` takes managed role names (`signoz-viewer`),
matching `change_user_role` and `create_service_account`.
- `find_role_by_name` moves to `fixtures/role.py` as a plain function
and replaces the `find_role_id` fixture — a stateless lookup shouldn't
be a fixture factory.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Additional Information

- `test_provision_user` now makes the provisioning calls inline, in
order, and covers the conflict branch that had no coverage before.
- `test_reset_password_v2` is gone; `test_reset_password` now targets v2
and absorbed its single-use-token assertion.
2026-08-12 12:41:15 +00:00
Tushar Vats
a3caaaf7f2 chore(frontend): regenerate ANTLR parser with search() support (#12491)
#### Description

- The frontend parser under `frontend/src/parser/` is generated from
`grammar/FilterQuery.g4` — the same grammar the backend query builder
uses — but the committed output predates the `search()` rule, so the UI
couldn't lex or parse `search('term')` even though the backend accepts
it. Regenerated it.
- Also fixed `scripts/grammar/generate-frontend-parser.sh`: ANTLR
reproduces the input's relative path under `-o`, so the old command
wrote to `frontend/src/parser/grammar/` instead of
`frontend/src/parser/`. It now runs from inside `grammar/`.
- Generated with ANTLR 4.13.2 (was 4.13.1), matching the `antlr4`
runtime in `frontend/package.json` and the version used for the Go
parser. That bump also adds `.js` extensions to the generated relative
imports — tsc (`moduleResolution: bundler`) and ts-jest resolve them
fine.
- Codegen only: no visitor/UI wiring for `search()` yet, so surfacing it
in autocomplete/validation is follow-up work.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5875

#### Additional Information

- Verified with a throwaway spec (not committed) that the regenerated
parser parses `search('error')`, `search('error', body, attribute)` and
`search('error') AND service.name = 'redis'` with zero syntax errors,
and still parses `has(payload.user_ids, 123)`.
- `src/parser/**` is in both the oxfmt and oxlint ignore lists, so the
raw ANTLR output is committed unformatted, as before. Because every
staged frontend file is ignored, lint-staged's `oxfmt --write` step
errors with "Expected at least one target file" — the commit needed
`--no-verify`.
- Ran the jest suites that consume the parser (`src/utils`,
`src/components/QueryBuilderV2`, `src/lib/recentQueries`): 371 passed, 1
pre-existing failure in `QuerySearch.test.tsx` that reproduces on `main`
without these changes.
2026-08-12 12:24:52 +00:00
Aditya Singh
a355996a5d feat(logs-explorer): resolve body json fields in logs table columns (#12503)
<!--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
In the logs explorer **table view**, a column for a field that lives
inside a JSON body now shows its value instead of coming up empty.
Scoped to `use_json_body` tenants.

- Added a wrapper util which sits on top of existing util which provides
the col values(FlatLogData).
- This searches for the attribute in body json. If its not present there
we will get it from attribute/resources as its happening currently.
- Only does this if `use_json_body` is true.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/4610
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before:
DB Operation col is empty
<img width="3402" height="1850" alt="image"
src="https://github.com/user-attachments/assets/7f8c6945-2c43-4ea6-9b82-5fd07b36c52f"
/>


After:
DB Operation col is populated from body

<img width="3346" height="1778" alt="image"
src="https://github.com/user-attachments/assets/c1ae2953-986e-42d5-bef3-9bc10e932fc2"
/>
2026-08-12 11:38:25 +00:00
Tushar Vats
eea11972a9 fix(logs): return the JSON body from v3 query_range (#12523)
#### Description

- For orgs on JSON bodies the collector writes the legacy `body` column
empty (`processBody` blanks it unless `body_json_old_body_enabled`) and
keeps the log body in `body_v2`. The v3 logs list still selected `body`,
so every log came back with an empty body — verified on a tenant: all
2159 rows had `body = ''` and `body_v2` populated.
- `queryRangeV3` now resolves `use_json_body` for the caller's org and
the list query selects `body_v2 as body`, the same expression v5 uses.
- `GetListResultV3` decodes JSON columns into a map, mirroring the
querier's raw-row consumption — the driver cannot decode JSON into
native Go values, so it is read as raw bytes and unmarshalled. A v3
response now carries the same body object v5 returns, so clients need no
change when they move to v5.

#### Additional Information

Scoped to the v3 endpoint: `QueryRangeV4` does not set the flag, so v4
keeps selecting the legacy column even though it shares the builder. The
livetail select is untouched — `/api/v3/logs/livetail` is served by the
v5 handler now, and `PrepareLiveTailQuery` has no callers.

One difference from v5 remains by choice: v5's `postProcessLogBody`
drops an empty `message` key, so a log with an empty message reads
`{"message":""}` here and `{}` there. Nothing branches on it, and
copying that logic would be a second implementation to keep in sync on a
path we are retiring.

#12520 is stacked on this branch — it types the pipelines preview log
body as `any`, which that endpoint needs before it starts receiving the
object bodies this PR returns.
2026-08-12 11:10:13 +00:00
133 changed files with 3783 additions and 1281 deletions

View File

@@ -487,8 +487,11 @@
// Simplifies boolean returns
"sonarjs/prefer-while": "error",
// Suggests while loops over for loops
"sonarjs/elseif-without-else": "off"
"sonarjs/elseif-without-else": "off",
// Requires final else in if-else-if chains (was disabled)
"signoz/no-conditional-text-nodes-with-siblings": "warn",
// Vendored from eslint-plugin-react-google-translate
"signoz/no-return-text-nodes": "warn"
},
"ignorePatterns": [
"src/parser/*.ts",

View File

@@ -16,6 +16,7 @@
"lint:generated": "oxlint ./src/api/generated --fix",
"lint:fix": "oxlint ./src --fix",
"lint:styles": "stylelint \"src/**/*.scss\"",
"test:plugins": "node --test \"plugins/__tests__/*.test.mjs\"",
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
@@ -125,6 +126,7 @@
"rrule": "2.8.1",
"styled-components": "^5.3.11",
"timestamp-nano": "^1.0.0",
"translation-resilience": "^0.2.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
"uuid": "14.0.1",

View File

@@ -0,0 +1,130 @@
# Plugin rule tests
Tests for the custom oxlint rules in `plugins/rules/`.
```bash
pnpm test:plugins
```
Runs on `node --test` rather than jest. The jest config is built for application
code — jsdom, ts-jest ESM transforms, a large `transformIgnorePatterns` wall —
and none of it applies to a suite whose only job is to shell out to the linter.
## Why it drives the real binary
Each case is written to a temp file and linted by the actual `oxlint` binary,
with every builtin category switched off so the only diagnostics that can appear
belong to the rule under test. Assertions therefore describe what CI enforces.
The alternative — walking the AST in-process — would need a stand-in for
oxlint's JS plugin AST. That AST is ESTree-shaped but not ESTree, and it carries
no type information, so a stand-in would drift from the runtime it claims to
model and the tests would certify behaviour that never happens.
All cases in a suite share one `oxlint` invocation and are mapped back by
filename. Per-case spawning costs roughly 80ms each; batching keeps both suites
together at around 250ms.
## Adding a suite
```js
import { ruleTester } from './rule-tester.mjs';
await ruleTester({
rule: 'no-navigator-clipboard',
valid: ['const x = 1;'],
invalid: [
{
code: 'navigator.clipboard.writeText("x");',
errors: [{ message: 'useCopyToClipboard', line: 1, column: 1 }],
},
],
});
```
`ruleTester` must be awaited at the top level — it loads the plugin and runs
`oxlint` before declaring the tests.
- `rule` — the key the plugin exports it under. `plugin` defaults to
`plugins/signoz.mjs`; pass a path relative to `frontend/` for another plugin.
- Cases are `.tsx` unless a `filename` gives another extension.
- `errors` takes a count or an array. Each entry may assert `message` (substring
or `RegExp`), `line` and `column`; omitted fields are not checked.
- `name` labels the case in the output and defaults to its first line of code.
- `output` asserts the source after suggestions are applied — see below.
- `todo` marks a case as a known defect — see below.
## Suggestions
Both Google Translate rules attach their wrap as a *suggestion*, not a fix, so
`--fix` leaves the code alone and `--fix-suggestions` applies it. The wrap is
`<span className="translate-safe">`, and `.translate-safe` is `display: contents`
in `src/styles.scss`: React owns an element that absorbs Translate's `<font>`
swap, while the box tree stays as it was, so a flex or grid parent still sees one
contiguous text run rather than a new item with its own `gap`.
It stays a suggestion because the element is still a DOM child even with no box:
`> *`, `:nth-child` and sibling selectors still count it, and a component that
inspects its children — `React.Children.map`, antd `Tooltip`, `Space` — sees an
element where a string used to be. That is what oxlint means by "May change
program behavior" in `--fix-suggestions --help`.
An invalid case carrying `output` is linted twice: once for diagnostics, and
once with `--fix-suggestions` over an untouched copy of the same files. The
second run costs one extra `oxlint` spawn per suite and only happens when at
least one case asks for it.
```js
{
code: "export const A = () => <div>{f ? 'a' : 'b'}<b/></div>;",
errors: 2,
output:
'export const A = () => <div>{f ? <span className="translate-safe">a</span> : <span className="translate-safe">b</span>}<b/></div>;',
}
```
Suggestions do not reformat, so a real run is `oxlint --fix-suggestions` then
`oxfmt`.
## Known defects
A case carrying `todo` asserts what the rule *should* do. It still runs, but a
failure is reported as a todo rather than failing the suite, so a bug can be
pinned as an executable spec instead of prose. Fixing the rule turns the todo
green; deleting the flag then makes it a regression guard.
Cases are prefixed `FP:` where the rule reports something it should not, `GAP:`
where it misses something it should catch, and `TYPE-AWARE:` where the miss is
only fixable once the linter can resolve types. Everything without a flag is a
characterisation test recording current behaviour.
The current todos:
**Gaps — constructs the rules never visit.** `isProblematicConditional` requires
a `JSXElement` parent, so a conditional inside a fragment is never inspected even
though the failure does not care about the parent's kind. `no-return-text-nodes`
listens only for `FunctionDeclaration` and reads the name off `node.id`, so
arrow-function components and anonymous default exports are invisible — this
codebase writes components as arrow functions, which is why that rule reports
nothing across `src`.
Class components are left out deliberately rather than pinned as a gap: there
are none in `src`.
**Type-aware gaps.** Upstream resolves branch types through
`@typescript-eslint/utils` and reports anything typed `string` or `number`.
oxlint's JS plugin runtime exposes no type information — `sourceCode.parserServices`
is always `{}` — so those code paths were removed rather than left dormant. The
`TYPE-AWARE:` todos record what they used to catch, and become the acceptance
criteria if oxlint ever hands types to JS plugins.
## Not a defect
Without types, `no-conditional-text-nodes-with-siblings` falls back to a callee
allowlist (`t`, `formatMessage`, `toString`, `toLocaleString`). Cases around
that allowlist pin its edges; widening it is the supported way to catch more
call expressions.
Both branches of a ternary are reported separately, so one fix can clear two
diagnostics. That inflates the count but every reported node is genuinely a text
node, so the cases assert both.

View File

@@ -0,0 +1,302 @@
import { ruleTester } from './rule-tester.mjs';
const CONDITIONAL = 'Conditionally rendered text nodes with siblings';
const PRECEDED = 'Text nodes which are preceded by a conditional expression';
await ruleTester({
rule: 'no-conditional-text-nodes-with-siblings',
valid: [
{
name: 'conditional text node without siblings',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t</div>\n);",
},
{
name: 'boolean branches are not text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? true : false}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'null branches are not text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? null : null}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'element branches are already wrapped',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? <b>y</b> : <i>n</i>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'text node before the conditional is safe',
code:
'export const A = () => (\n\t<div>\n\t\tleading text\n\t\t{flag && <b>y</b>}\n\t</div>\n);',
},
{
name: 'member expression on the condition side is not rendered',
code:
'export const A = () => (\n\t<div>\n\t\t{obj.name && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'binary comparison on the condition side is not rendered',
code:
"export const A = () => (\n\t<div>\n\t\t{obj.name === 'x' && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);",
},
// An empty string renders no text node at all, so there is nothing for
// Google Translate to wrap and nothing for React to lose. Reporting it used
// to be the rule's most common false positive.
{
name: 'element branch with an empty-string fallback',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? <b>Free Trial</b> : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'both branches empty',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? '' : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'logical and with an empty-string right-hand side',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
// A template literal is checked the same way as the quoted form, so `{' '}`
// and ``{` `}`` agree.
{
name: 'empty template literal branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? `` : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'whitespace-only template literal branch is skipped',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? ` ` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
},
],
invalid: [
{
name: 'string literal branches with an element sibling',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 19 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">yes</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'logical and with a string right-hand side',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && 'yes'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 12 }],
},
{
name: 'numeric literals render as text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? 1 : 2}\n\t\t<span>x</span>\n\t</div>\n);',
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 15 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{1}</span> : <span className="translate-safe">{2}</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'template literal branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? `yes ${n}` : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 24 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{`yes ${n}`}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'member expression branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? obj.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 22 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{obj.name}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'a string needing escapes stays inside braces',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? "it\'s" : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{"it\'s"}</span> : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
},
{
name: 'optional chaining branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? obj?.deep?.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 29 },
],
},
{
name: 'nested ternary reports every text branch',
code:
"export const A = () => (\n\t<div>\n\t\t{a ? (b ? 'x' : 'y') : 'z'}\n\t\t<span>s</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 13 },
{ message: CONDITIONAL, line: 3, column: 19 },
{ message: CONDITIONAL, line: 3, column: 26 },
],
},
{
name: 'static text following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\ttrailing text\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 3, column: 21 }],
// Only the visible run is wrapped; the surrounding newlines and tabs are
// formatting and must stay outside the element.
output:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">trailing text</span>\n\t</div>\n);',
},
{
name: 'conditional text plus trailing static text reports both kinds',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'a' : 'b'}\n\t\tliteral tail\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 17 },
{ message: PRECEDED, line: 3, column: 21 },
],
},
// The callee allowlist below is the untyped fallback. Without type
// information the rule can only recognise known string-returning helpers,
// so `t()` and `formatMessage()` are reported while an arbitrary call is
// not. These cases pin that boundary.
{
name: 't() branch is reported via the callee allowlist',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? t('key') : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 22 },
],
},
{
name: 'formatMessage() branch is reported via the callee allowlist',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? formatMessage({id:'k'}) : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 37 },
],
},
{
name: 'arbitrary call is not recognised, only the literal branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 25 }],
},
{
name: 'bare identifier is not recognised, only the literal branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 18 }],
},
{
name: 'toString() branch is reported',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 28 },
],
},
{
name: 'toLocaleString() branch is reported',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toLocaleString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 34 },
],
},
{
name: 't() in its own container following a conditional',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{t('key')}\n\t</div>\n);",
errors: [{ message: PRECEDED, line: 4, column: 4 }],
},
{
name: 'toString() in its own container following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{val.toString()}\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 4, column: 4 }],
// The whole container is replaced, so the result is not `{<span>{…}</span>}`.
output:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">{val.toString()}</span>\n\t</div>\n);',
},
{
name: 'whitespace-only string branch is skipped, the other branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? ' ' : 'x'}\n\t\t<span>s</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 17 }],
},
{
name: 'template literal holding an expression is not blank',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? `${n}` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
},
// Upstream resolves branch types through `@typescript-eslint/utils` and
// reports anything typed `string` or `number`. oxlint's JS plugin runtime
// exposes no type information, so those paths were dropped and only the
// callee allowlist remains. Kept as todos: if oxlint ever hands types to JS
// plugins these become the acceptance criteria.
{
todo: 'needs type information to know the call returns a string',
name: 'TYPE-AWARE: call returning a string',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 25 },
],
},
{
todo: 'needs type information to know the identifier is a string',
name: 'TYPE-AWARE: identifier holding a string',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 18 },
],
},
{
todo: 'needs type information to know the identifier is a string',
name: 'TYPE-AWARE: string identifier following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{label}\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 4, column: 4 }],
},
// `isChildOfJSXElement` matches only `JSXElement`, so a fragment parent is
// never inspected. The Google Translate failure does not care whether the
// parent is an element or a fragment.
{
todo: 'fragment parents are never inspected',
name: 'GAP: conditional text with a sibling inside a fragment',
code:
"export const A = () => (\n\t<>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 19 },
],
},
],
});

View File

@@ -0,0 +1,162 @@
import { ruleTester } from './rule-tester.mjs';
const RETURNS_TEXT = 'React components should avoid returning text nodes';
await ruleTester({
rule: 'no-return-text-nodes',
valid: [
{
name: 'lowercase function is not a component',
code: "export function foo() {\n\treturn 'text';\n}",
},
{
name: 'returning JSX',
code: 'export function Foo() {\n\treturn <div>hi</div>;\n}',
},
{
name: 'returning null',
code: 'export function Foo() {\n\treturn null;\n}',
},
{
name: 'returning boolean',
code: 'export function Foo() {\n\treturn true;\n}',
},
{ name: 'bare return', code: 'export function Foo() {\n\treturn;\n}' },
{
name: 'returning a variable is not a literal',
code: "export function Foo() {\n\tconst s = 'x';\n\treturn s;\n}",
},
{
name: 'lowercase nested function inside a component',
code:
"export function Foo() {\n\tfunction helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
},
{
// The repo has no class components, so this is out of scope rather than
// a gap worth closing.
name: 'class method',
code: "export class Foo {\n\trender() {\n\t\treturn 'text';\n\t}\n}",
},
],
invalid: [
{
name: 'string literal',
code: "export function Foo() {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
output:
'export function Foo() {\n\treturn <span className="translate-safe">{\'text\'}</span>;\n}',
},
{
// JSX does not parse in a `.ts` file, so no suggestion is offered there.
name: 'string literal in a non-JSX file',
filename: 'case.ts',
code: "export function Foo() {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
output: "export function Foo() {\n\treturn 'text';\n}",
},
{
name: 'numeric literal',
code: 'export function Foo() {\n\treturn 42;\n}',
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
name: 'template literal',
code: 'export function Foo() {\n\treturn `text ${x}`;\n}',
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
name: 'inside an if consequent',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside an else block',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else {\n\t\treturn 'x';\n\t}\n}",
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
},
{
name: 'inside an else-if chain',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else if (b) {\n\t\treturn 'x';\n\t}\n\treturn null;\n}",
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
},
{
name: 'inside a switch case',
code:
"export function Foo() {\n\tswitch (a) {\n\t\tcase 1:\n\t\t\treturn 'x';\n\t\tdefault:\n\t\t\treturn <div/>;\n\t}\n}",
errors: [{ message: RETURNS_TEXT, line: 4, column: 4 }],
},
{
name: 'inside try, catch and finally',
code:
"export function Foo() {\n\ttry {\n\t\treturn 'a';\n\t} catch {\n\t\treturn 'b';\n\t} finally {\n\t\treturn 'c';\n\t}\n}",
errors: [
{ message: RETURNS_TEXT, line: 3, column: 3 },
{ message: RETURNS_TEXT, line: 5, column: 3 },
{ message: RETURNS_TEXT, line: 7, column: 3 },
],
},
{
name: 'inside a for loop',
code:
"export function Foo() {\n\tfor (let i = 0; i < 3; i++) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a for-of loop',
code:
"export function Foo() {\n\tfor (const i of list) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a for-in loop',
code:
"export function Foo() {\n\tfor (const k in obj) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a while loop',
code:
"export function Foo() {\n\twhile (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a do-while loop',
code: "export function Foo() {\n\tdo {\n\t\treturn 'x';\n\t} while (a);\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'capitalised nested function is treated as a component',
code:
"export function Foo() {\n\tfunction Helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
// The rule listens only for `FunctionDeclaration` and reads the component
// name off `node.id`. Everything below returns a text node from something
// React renders as a component, and none of it is reported. This codebase
// writes components as arrow functions, which is why the rule currently
// finds nothing in `src`.
{
todo: 'arrow function components are never visited',
name: 'GAP: arrow component with an expression body',
code: "export const Foo = () => 'text';",
errors: 1,
},
{
todo: 'arrow function components are never visited',
name: 'GAP: arrow component with a block body',
code: "export const Foo = () => {\n\treturn 'text';\n};",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
todo: 'anonymous declarations have no node.id to read a name from',
name: 'GAP: anonymous default-exported component',
code: "export default function () {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
],
});

View File

@@ -0,0 +1,257 @@
/**
* Test harness for oxlint JS plugins.
*
* Rules are exercised through the real `oxlint` binary rather than a hand-rolled
* AST walker, so what the tests assert is exactly what CI enforces. oxlint's JS
* plugin AST is close to ESTree but not identical, and it exposes no type
* information, so any in-process fake would drift from the real runtime.
*
* All cases in a suite are written to one temp directory and linted in a single
* oxlint invocation, then mapped back by filename. Spawning per case costs ~80ms
* each; batching keeps a full suite under a second.
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
const FRONTEND_DIR = path.resolve(fileURLToPath(import.meta.url), '../../..');
const OXLINT_BIN = path.join(FRONTEND_DIR, 'node_modules/.bin/oxlint');
// oxlint enables its default categories unless every one is switched off, and a
// stray builtin diagnostic would be indistinguishable from the rule under test.
const CATEGORIES_OFF = {
correctness: 'off',
suspicious: 'off',
pedantic: 'off',
perf: 'off',
style: 'off',
restriction: 'off',
nursery: 'off',
};
function normaliseCase(entry, index) {
const testCase = typeof entry === 'string' ? { code: entry } : entry;
const extension = testCase.filename
? path.extname(testCase.filename).slice(1)
: 'tsx';
return {
...testCase,
index,
basename: `case-${String(index).padStart(3, '0')}.${extension}`,
};
}
function diagnosticFilename(diagnostic) {
const raw = diagnostic.filename ?? '';
const asPath = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
return path.basename(asPath);
}
function toError(diagnostic) {
const span = diagnostic.labels?.[0]?.span;
return {
message: diagnostic.message,
line: span?.line,
column: span?.column,
};
}
function runOxlint(dir, configPath, extraArgs = []) {
const args = ['--config', configPath, '--format', 'json', ...extraArgs, '.'];
try {
return execFileSync(OXLINT_BIN, args, {
cwd: dir,
encoding: 'utf8',
// A rule that reports on every case can produce a lot of output.
maxBuffer: 64 * 1024 * 1024,
});
} catch (error) {
// oxlint exits non-zero whenever it reports a diagnostic, which is the
// expected outcome for every `invalid` case.
if (typeof error.stdout === 'string' && error.stdout.trim() !== '') {
return error.stdout;
}
throw new Error(`oxlint failed to run:\n${error.stderr || error.message}`, {
cause: error,
});
}
}
function writeSuite(dir, cases, { pluginPath, ruleId }) {
for (const testCase of cases) {
const target = path.join(dir, testCase.basename);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, testCase.code);
}
const configPath = path.join(dir, '.oxlintrc.json');
writeFileSync(
configPath,
JSON.stringify({
jsPlugins: [pluginPath],
categories: CATEGORIES_OFF,
rules: { [ruleId]: 'error' },
}),
);
return configPath;
}
/**
* Lints every case in one pass, and applies suggestions in a second pass over an
* untouched copy when any case declares `output`.
*
* @returns {{errors: Map<string, object[]>, outputs: Map<string, string>}}
*/
function lintCases(cases, options) {
const root = mkdtempSync(path.join(tmpdir(), 'oxlint-rule-tester-'));
try {
const lintDir = path.join(root, 'lint');
mkdirSync(lintDir);
const report = JSON.parse(
runOxlint(lintDir, writeSuite(lintDir, cases, options)),
);
const errors = new Map(cases.map((testCase) => [testCase.basename, []]));
for (const diagnostic of report.diagnostics ?? []) {
const bucket = errors.get(diagnosticFilename(diagnostic));
// oxlint reports config-level problems without a filename; surfacing
// them as a suite failure beats silently testing nothing.
if (!bucket) {
throw new Error(`Unexpected diagnostic: ${diagnostic.message}`);
}
bucket.push(toError(diagnostic));
}
const outputs = new Map();
if (cases.some((testCase) => testCase.output !== undefined)) {
const fixDir = path.join(root, 'fix');
mkdirSync(fixDir);
runOxlint(fixDir, writeSuite(fixDir, cases, options), ['--fix-suggestions']);
for (const testCase of cases) {
outputs.set(
testCase.basename,
readFileSync(path.join(fixDir, testCase.basename), 'utf8'),
);
}
}
return { errors, outputs };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function assertMessage(actual, expected, label) {
if (expected instanceof RegExp) {
assert.match(actual, expected, label);
} else {
assert.ok(
actual.includes(expected),
`${label}\n expected message to contain: ${expected}\n actual: ${actual}`,
);
}
}
function assertErrors(actual, expected, code) {
const context = `\n--- code ---\n${code}\n--- reported ---\n${JSON.stringify(actual, null, 2)}`;
if (typeof expected === 'number') {
assert.equal(actual.length, expected, `error count${context}`);
return;
}
assert.equal(actual.length, expected.length, `error count${context}`);
expected.forEach((want, i) => {
const got = actual[i];
if (want.message !== undefined) {
assertMessage(got.message, want.message, `error[${i}] message${context}`);
}
if (want.line !== undefined) {
assert.equal(got.line, want.line, `error[${i}] line${context}`);
}
if (want.column !== undefined) {
assert.equal(got.column, want.column, `error[${i}] column${context}`);
}
});
}
/**
* Declares a suite for one rule.
*
* A case carrying `todo` asserts the behaviour the rule *should* have. It still
* runs, but a failure is reported as a todo instead of failing the suite, so a
* known bug can be pinned as an executable spec. Delete the flag once the rule
* is fixed and the case starts guarding the fix.
*
* An invalid case carrying `output` also asserts the source after
* `--fix-suggestions` has been applied.
*
* @param {object} options
* @param {string} options.rule - rule name as exported by the plugin
* @param {string} [options.plugin] - path to the plugin, relative to `frontend/`
* @param {Array<string | {code: string, name?: string, filename?: string, todo?: string}>} options.valid
* @param {Array<{code: string, name?: string, filename?: string, todo?: string, output?: string, errors: number | Array<{message?: string | RegExp, line?: number, column?: number}>}>} options.invalid
*/
export async function ruleTester({
rule,
plugin = 'plugins/signoz.mjs',
valid = [],
invalid = [],
}) {
const pluginPath = path.join(FRONTEND_DIR, plugin);
const { default: pluginModule } = await import(pathToFileURL(pluginPath));
assert.ok(
pluginModule.rules?.[rule],
`plugin ${plugin} does not export a rule named "${rule}"`,
);
const ruleId = `${pluginModule.meta.name}/${rule}`;
const validCases = valid.map(normaliseCase);
const invalidCases = invalid.map((entry, i) =>
normaliseCase(entry, valid.length + i),
);
const { errors, outputs } = lintCases([...validCases, ...invalidCases], {
pluginPath,
ruleId,
});
const declare = (t, testCase, expected) => {
const label = testCase.name ?? testCase.code.trim().split('\n')[0];
return t.test(label, { todo: testCase.todo }, () => {
assertErrors(errors.get(testCase.basename), expected, testCase.code);
if (testCase.output !== undefined) {
assert.equal(
outputs.get(testCase.basename),
testCase.output,
`suggestion output\n--- code ---\n${testCase.code}`,
);
}
});
};
test(ruleId, async (t) => {
await t.test('valid', async (t) => {
for (const testCase of validCases) {
await declare(t, testCase, 0);
}
});
await t.test('invalid', async (t) => {
for (const testCase of invalidCases) {
await declare(t, testCase, testCase.errors);
}
});
});
}

View File

@@ -0,0 +1,313 @@
/**
* Rule: no-conditional-text-nodes-with-siblings
*
* Conditionally rendered text nodes with siblings should be wrapped in an
* element (for example a `<span>`), otherwise Google Translate causes a browser
* error. Translate replaces the text node with a `<font>` wrapper, React still
* holds a reference to the original node, and the next render throws on
* `removeChild`.
*
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
* (v1.0.4). The upstream rule resolves branch types through
* `@typescript-eslint/utils`; oxlint's JS plugin runtime exposes no type
* information, so those paths are dropped and call expressions are matched
* against the allowlist below instead.
*/
// Calls known to render as text. Without types this is the only way to
// recognise a string-returning call; widen it to catch more helpers.
const TEXT_RETURNING_CALLEES = new Set(['formatMessage', 't']);
const STRINGIFY_METHODS = new Set(['toString', 'toLocaleString']);
function calleeName(node) {
return node.type === 'Identifier' ? node.name : null;
}
function isTextReturningCall(node) {
const { callee } = node;
if (TEXT_RETURNING_CALLEES.has(calleeName(callee))) {
return node.arguments.length > 0;
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return STRINGIFY_METHODS.has(calleeName(callee.property));
}
return STRINGIFY_METHODS.has(calleeName(callee));
}
/**
* True when the node renders no visible text. An empty or whitespace-only value
* produces no DOM text node, so Google Translate has nothing to wrap and React
* nothing to lose.
*/
function isBlankText(node) {
if (node.type === 'Literal' || node.type === 'JSXText') {
return typeof node.value === 'string' && node.value.trim() === '';
}
if (node.type === 'TemplateLiteral') {
return (
node.expressions.length === 0 &&
node.quasis.every((quasi) => (quasi.value.cooked ?? '').trim() === '')
);
}
return false;
}
function isConditionallyRendered(node) {
const parent = node.parent;
return (
parent?.type === 'ConditionalExpression' ||
parent?.type === 'LogicalExpression'
);
}
function isRenderedConditional(node) {
return (
node.type === 'JSXExpressionContainer' &&
(node.expression?.type === 'ConditionalExpression' ||
node.expression?.type === 'LogicalExpression')
);
}
/** Children that produce output, i.e. everything but formatting whitespace. */
function renderedChildren(node) {
const children = node?.children;
if (!children) {
return null;
}
return children.filter((child) => !isBlankText(child));
}
/** True when `node` is a JSX child rendered alongside at least one other child. */
function hasSiblings(node) {
if (!(node?.parent?.children?.length > 1)) {
return false;
}
return renderedChildren(node.parent).some((child) => child !== node);
}
function isPrecededByConditional(node) {
const children = renderedChildren(node?.parent);
if (!children) {
return false;
}
return children.some(
(child) => child.start < node.start && isRenderedConditional(child),
);
}
/** Walk out of nested conditionals so nested branches report against the outer container. */
function getOutermostConditional(node) {
let current = node;
while (isConditionallyRendered(current)) {
current = current.parent;
}
return current;
}
/** True when `node` is a conditional branch rendered directly beside other JSX children. */
function isProblematicConditional(node) {
if (!isConditionallyRendered(node)) {
return false;
}
const container = getOutermostConditional(node);
return (
container.parent?.type === 'JSXExpressionContainer' &&
container.parent.parent?.type === 'JSXElement' &&
hasSiblings(container.parent)
);
}
/** True when `node` renders after a sibling conditional, i.e. the DOM order Translate breaks. */
function followsConditionalSibling(node) {
return (
node.parent?.parent?.type === 'JSXElement' &&
hasSiblings(node.parent) &&
isPrecededByConditional(node.parent)
);
}
/**
* `A && B` and the test of a ternary are conditions, not rendered output.
*/
function isCondition(node) {
let current = node;
while (current.parent?.type === 'LogicalExpression') {
if (current.parent.left === current) {
return true;
}
current = current.parent;
}
if (current.parent?.type === 'ConditionalExpression') {
return current.parent.test === current;
}
return false;
}
function isConditionOperand(node) {
if (node.parent?.type === 'BinaryExpression') {
return isCondition(node.parent);
}
return isCondition(node);
}
// A string may only be inlined as JSX text when it needs no escaping and no
// whitespace of its own: JSX collapses leading and trailing whitespace, and
// these characters would either close the element or start an entity.
const NEEDS_BRACES = /['"{}<>&\r\n]/;
// `display: contents`, declared in src/styles.scss. React owns the element so
// Translate's `<font>` swap is absorbed, while the box tree stays as it was and
// a flex or grid parent still sees one contiguous text run.
const OPEN = '<span className="translate-safe">';
const CLOSE = '</span>';
/** Wraps the reported expression so React owns an element Translate cannot replace. */
function wrapExpression(fixer, sourceCode, node) {
// A call reported on its own already sits in a container. Replacing the
// container yields `<span …>{expr}</span>` rather than `{<span …>{expr}</span>}`.
const target =
node.parent?.type === 'JSXExpressionContainer' ? node.parent : node;
if (
node.type === 'Literal' &&
typeof node.value === 'string' &&
!NEEDS_BRACES.test(node.value) &&
node.value.trim() === node.value
) {
return fixer.replaceText(target, `${OPEN}${node.value}${CLOSE}`);
}
return fixer.replaceText(
target,
`${OPEN}{${sourceCode.getText(node)}}${CLOSE}`,
);
}
/**
* Wraps static JSX text. Only the visible run is wrapped: the node also spans
* the formatting whitespace around it, which has to stay outside the element.
*/
function wrapJsxText(fixer, node) {
const raw = node.value;
const leading = raw.length - raw.trimStart().length;
const trailing = raw.length - raw.trimEnd().length;
return fixer.replaceTextRange(
[node.start + leading, node.end - trailing],
`${OPEN}${raw.trim()}${CLOSE}`,
);
}
export default {
meta: {
type: 'problem',
docs: {
description:
'Conditionally rendered text nodes should be wrapped in an element (for example a `<span>`), otherwise Google Translate can cause a browser error.',
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
},
schema: [],
// Wrapping adds a DOM element, which can turn into a flex/grid item or
// break `> *` and `:nth-child` selectors, so it is offered as a suggestion
// (`--fix-suggestions`) rather than applied by a bare `--fix`.
hasSuggestions: true,
messages: {
'conditional-text-node':
'Conditionally rendered text nodes with siblings, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">{value}</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`. This also applies to values returned from functions, so `getString()` becomes `<span className="translate-safe">{getString()}</span>`.',
'text-node-preceded-by-conditional':
'Text nodes which are preceded by a conditional expression, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">text</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`.',
},
},
createOnce(context) {
const suggestWrap = (build) => [{ desc: 'Wrap in a <span>', fix: build }];
const wrap = (fixer, node) => wrapExpression(fixer, context.sourceCode, node);
const reportConditional = (node) => {
context.report({
node,
messageId: 'conditional-text-node',
suggest: suggestWrap((fixer) => wrap(fixer, node)),
});
};
const reportPreceded = (node, build) => {
context.report({
node,
messageId: 'text-node-preceded-by-conditional',
suggest: suggestWrap(build),
});
};
return {
// String and numeric branches: `{flag ? 'yes' : 'no'}`
Literal(node) {
if (node.value === null || typeof node.value === 'boolean') {
return;
}
if (isBlankText(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
TemplateLiteral(node) {
if (isBlankText(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
// Static text rendered after a conditional: `{flag && <b/>}trailing`
JSXText(node) {
if (isBlankText(node)) {
return;
}
if (hasSiblings(node) && isPrecededByConditional(node)) {
reportPreceded(node, (fixer) => wrapJsxText(fixer, node));
}
},
CallExpression(node) {
if (isCondition(node) || !isTextReturningCall(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
if (followsConditionalSibling(node)) {
reportPreceded(node, (fixer) => wrap(fixer, node));
}
},
// Values read off an object: `{flag ? user.name : 'anonymous'}`
MemberExpression(node) {
if (isConditionOperand(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
// Optional chaining wraps the member expression: `{flag ? a?.b?.c : 'x'}`
ChainExpression(node) {
if (isConditionOperand(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
};
},
};

View File

@@ -0,0 +1,115 @@
/**
* Rule: no-return-text-nodes
*
* React components should not return a bare text node. Google Translate keeps
* displaying the stale translated text after a state change and nothing throws,
* which makes the bug very hard to track down. Numbers count too: JSX renders
* them as text.
*
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
* (v1.0.4). Upstream walks the function body statement by statement; this
* version visits `ReturnStatement` directly and walks up to the enclosing
* function, which covers the same constructs without enumerating them.
*/
const FUNCTION_TYPES = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
]);
function isTextNode(node) {
if (!node) {
return false;
}
if (node.type === 'TemplateLiteral') {
return true;
}
return (
node.type === 'Literal' &&
(typeof node.value === 'string' || typeof node.value === 'number')
);
}
function getEnclosingFunction(node) {
let current = node.parent;
while (current) {
if (FUNCTION_TYPES.has(current.type)) {
return current;
}
current = current.parent;
}
return null;
}
function isComponentName(name) {
return (
typeof name === 'string' && name !== '' && name[0] === name[0].toUpperCase()
);
}
// The suggestion introduces JSX, which only parses in a JSX-enabled file.
function allowsJsx(filename) {
return filename.endsWith('.tsx') || filename.endsWith('.jsx');
}
export default {
meta: {
type: 'problem',
docs: {
description:
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
},
schema: [],
// Wrapping changes what the component renders, so it is offered as a
// suggestion (`--fix-suggestions`) rather than applied by a bare `--fix`.
hasSuggestions: true,
messages: {
'return-value-is-text-node':
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
},
},
createOnce(context) {
const buildSuggestion = (node) => {
if (!allowsJsx(context.filename)) {
return undefined;
}
return [
{
desc: 'Wrap in a <span>',
fix: (fixer) =>
fixer.replaceText(
node.argument,
`<span className="translate-safe">{${context.sourceCode.getText(node.argument)}}</span>`,
),
},
];
};
return {
ReturnStatement(node) {
if (!isTextNode(node.argument)) {
return;
}
// Only named function declarations are recognised as components, so a
// text return from a nested helper or a class method is left alone.
const fn = getEnclosingFunction(node);
if (fn?.type !== 'FunctionDeclaration') {
return;
}
if (!isComponentName(fn.id?.name)) {
return;
}
context.report({
node,
messageId: 'return-value-is-text-node',
suggest: buildSuggestion(node),
});
},
};
},
};

View File

@@ -13,6 +13,8 @@ import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
export default {
meta: {
@@ -27,5 +29,7 @@ export default {
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
'no-return-text-nodes': noReturnTextNodes,
},
};

View File

@@ -294,6 +294,9 @@ importers:
timestamp-nano:
specifier: ^1.0.0
version: 1.0.1
translation-resilience:
specifier: ^0.2.0
version: 0.2.0
typescript:
specifier: 5.9.3
version: 5.9.3
@@ -8475,6 +8478,9 @@ packages:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
translation-resilience@0.2.0:
resolution: {integrity: sha512-IxTjhpHGp1SJxVEEPBu/YbBaHnymMRJdYxUY1i5qtYADuz9b8fdWhZSE/EeRS2aVIiuQjRqtYDk69ruS/3fzTg==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -18232,6 +18238,8 @@ snapshots:
dependencies:
punycode: 2.3.1
translation-resilience@0.2.0: {}
trim-lines@3.0.1: {}
trough@2.1.0: {}

View File

@@ -10,6 +10,10 @@ jest.mock('providers/Timezone', () => ({
}),
}));
jest.mock('providers/App/App', () => ({
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
const field = (name: string, type = ''): IField => ({
name,
type,

View File

@@ -2,13 +2,15 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { FlatLogData } from 'lib/logs/flatLogData';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -26,6 +28,10 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -88,7 +94,8 @@ export function useLogsTableColumns({
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
accessorFn: (log): unknown =>
getLogFieldValue(log, f.name, isBodyJsonEnabled),
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
@@ -115,5 +122,5 @@ export function useLogsTableColumns({
.filter((c): c is TableColumnDef<ILog> => c !== null);
return [stateIndicatorCol, ...fieldCols];
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
}, [fields, fontSize, formatTimezoneAdjustedTimestamp, isBodyJsonEnabled]);
}

View File

@@ -59,6 +59,7 @@ import {
dedupeOptionsByLabel,
getFieldContextPrefix,
getRecentOptions,
isSupportedFunction,
renderRecentDeleteButton,
} from './utils';
@@ -1275,11 +1276,13 @@ function QuerySearch({
}
if (queryContext.isInFunction) {
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
options = Object.values(QUERY_BUILDER_FUNCTIONS)
.filter((option) => isSupportedFunction(option, dataSource))
.map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);

View File

@@ -1,8 +1,12 @@
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import { DataSource } from 'types/common/queryBuilder';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
getFieldContextPrefix,
getUserExpressionFromCombined,
isSupportedFunction,
} from '../utils';
describe('entityLogsExpression', () => {
@@ -118,3 +122,19 @@ describe('dedupeOptionsByLabel', () => {
expect(dedupeOptionsByLabel([])).toStrictEqual([]);
});
});
describe('isSupportedFunction', () => {
const { HASANY, SEARCH } = QUERY_BUILDER_FUNCTIONS;
it('allows the has family on every signal', () => {
[DataSource.LOGS, DataSource.TRACES, DataSource.METRICS].forEach((signal) => {
expect(isSupportedFunction(HASANY, signal)).toBe(true);
});
});
it('allows search on logs only', () => {
expect(isSupportedFunction(SEARCH, DataSource.LOGS)).toBe(true);
expect(isSupportedFunction(SEARCH, DataSource.TRACES)).toBe(false);
expect(isSupportedFunction(SEARCH, DataSource.METRICS)).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
@@ -15,6 +16,15 @@ import {
RECENTS_SECTION,
} from './constants';
// search() lives in the logs condition builder only; traces and metrics reject it
// as an unsupported operator. Every other function is implemented for all signals.
export function isSupportedFunction(
functionName: string,
signal: SignalType,
): boolean {
return functionName !== QUERY_BUILDER_FUNCTIONS.SEARCH || signal === 'logs';
}
export interface FieldContextPrefixMatch {
context: string;
remainder: string;

View File

@@ -0,0 +1,280 @@
import {
completionStatus,
currentCompletions,
startCompletion,
} from '@codemirror/autocomplete';
import { EditorView } from '@uiw/react-codemirror';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { RECENTS_DISPLAY_CAP, RECENTS_SECTION } from '../QuerySearch/constants';
import QuerySearch from '../QuerySearch/QuerySearch';
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
const CM_ROOT_SELECTOR = '.cm-editor';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
const TOOLTIP_SELECTOR = '.cm-tooltip-autocomplete';
const COMPLETION_LABEL_SELECTOR = '.cm-completionLabel';
const DELETE_BUTTON_SELECTOR = '.cm-recent-delete';
const FRONTEND_FILTER = "service.name = 'frontend'";
const STATUS_CODE_FILTER = "http.status_code = '500'";
const TRACES_FILTER = "name = 'HTTP GET'";
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
function renderLogsSearch(onChange: (value: string) => void = jest.fn()): void {
render(
<QuerySearch
onChange={onChange}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
}
function saveLogsRecent(expression: string): void {
recentQueriesStore.save({ signal: 'logs', filter: { expression } });
}
function getEditorView(): EditorView | null {
const root = document.querySelector<HTMLElement>(CM_ROOT_SELECTOR);
return root ? EditorView.findFromDOM(root) : null;
}
function getDocText(): string {
return getEditorView()?.state.doc.toString() ?? '';
}
function isCompletionOpen(): boolean {
const view = getEditorView();
return !!view && completionStatus(view.state) === 'active';
}
// Reads recents from completion state, not the tooltip: the tooltip is a later render
// pass over this same state, so going to the source drops a layer of timing.
function getRecentLabels(): string[] {
const view = getEditorView();
if (!view) {
return [];
}
return currentCompletions(view.state)
.filter((completion) => completion.section === RECENTS_SECTION)
.map((completion) => completion.label);
}
async function renderAndFocus(
onChange: (value: string) => void = jest.fn(),
): Promise<HTMLElement> {
renderLogsSearch(onChange);
const editor = await waitFor(
() => {
const element = document.querySelector(CM_EDITOR_SELECTOR);
expect(element).toBeInTheDocument();
return element as HTMLElement;
},
{ timeout: 2000 },
);
await userEvent.click(editor);
return editor;
}
function openRecents(): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
expect(getRecentLabels().length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
}
describe('QuerySearch recent searches', () => {
beforeEach(() => {
recentQueriesStore.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
});
it('shows a saved recent query under "Recent searches" on focus', async () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
);
const view = getEditorView() as EditorView;
const [recent] = currentCompletions(view.state);
expect(recent.section).toBe(RECENTS_SECTION);
});
it('filters recents by substring as the user types', async () => {
saveLogsRecent(FRONTEND_FILTER);
saveLogsRecent(STATUS_CODE_FILTER);
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, 'status_code');
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([STATUS_CODE_FILTER]);
},
{ timeout: 3000 },
);
});
it('does not surface recents saved under a different signal', async () => {
recentQueriesStore.save({
signal: 'traces',
filter: { expression: TRACES_FILTER },
});
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
);
});
it('excludes a recent that exactly matches the current editor text', async () => {
const supersetFilter = `${FRONTEND_FILTER} AND ${STATUS_CODE_FILTER}`;
saveLogsRecent(FRONTEND_FILTER);
saveLogsRecent(supersetFilter);
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, FRONTEND_FILTER);
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([supersetFilter]);
},
{ timeout: 3000 },
);
});
it('caps the dropdown at RECENTS_DISPLAY_CAP entries, newest first', async () => {
const filters = Array.from(
{ length: RECENTS_DISPLAY_CAP + 1 },
(_, index) => `attribute_${index + 1} = 'v'`,
);
filters.forEach((filter) => saveLogsRecent(filter));
const expectedLabels = [...filters].reverse().slice(0, RECENTS_DISPLAY_CAP);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual(expectedLabels);
},
{ timeout: 3000 },
);
});
it('applies the full expression to the editor when a recent is clicked', async () => {
saveLogsRecent(FRONTEND_FILTER);
const onChange = jest.fn();
await renderAndFocus(onChange);
await openRecents();
const option = await waitFor(
() => {
const node = Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER);
expect(node).toBeDefined();
return node as HTMLElement;
},
{ timeout: 3000 },
);
await userEvent.click(option);
await waitFor(
() => {
expect(getDocText()).toBe(FRONTEND_FILTER);
},
{ timeout: 2000 },
);
expect(onChange).toHaveBeenCalledWith(FRONTEND_FILTER);
await waitFor(
() => {
expect(document.querySelector(TOOLTIP_SELECTOR)).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it('removes a recent from the dropdown and the store when delete is clicked', async () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
const deleteButton = await waitFor(
() => {
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
expect(button).toBeInTheDocument();
return button as HTMLElement;
},
{ timeout: 3000 },
);
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.
fireEvent.click(deleteButton);
await waitFor(
() => {
expect(recentQueriesStore.list('logs')).toHaveLength(0);
expect(getRecentLabels()).not.toContain(FRONTEND_FILTER);
},
{ timeout: 2000 },
);
expect(getDocText()).toBe('');
});
});

View File

@@ -41,6 +41,7 @@ export const QUERY_BUILDER_FUNCTIONS = {
HASANY: 'hasAny',
HASALL: 'hasAll',
HASTOKEN: 'hasToken',
SEARCH: 'search',
};
export function negateOperator(operatorOrFunction: string): string {

View File

@@ -27,7 +27,6 @@ export interface BaseConfigBuilderProps {
panelType: PANEL_TYPES;
minTimeScale?: number;
maxTimeScale?: number;
useExactTimeRange?: boolean;
stepInterval?: number;
isLogScale?: boolean;
yAxisUnit?: string;
@@ -47,7 +46,6 @@ export function buildBaseConfig({
thresholds,
minTimeScale,
maxTimeScale,
useExactTimeRange,
stepInterval,
isLogScale,
yAxisUnit,
@@ -90,7 +88,6 @@ export function buildBaseConfig({
time: true,
min: minTimeScale,
max: maxTimeScale,
useExactTimeRange,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic
@@ -127,7 +124,9 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
panelType,
// Graph and bar plot time on X; every other panel type here does not.
isTimeAxis:
panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR,
});
builder.addAxis({
@@ -137,7 +136,6 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -1,5 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -76,7 +75,7 @@ export function buildEntityMetricsChartConfig({
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
});
builder.addAxis({
@@ -85,7 +84,6 @@ export function buildEntityMetricsChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {

View File

@@ -1617,13 +1617,13 @@ export const getNamespaceMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'max',
stepInterval: 60,
timeAggregation: 'avg',
timeAggregation: 'latest',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
expression: '(B/A) * 100',
legend: 'util %',
queryName: 'F1',
},

View File

@@ -1,5 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
@@ -73,7 +72,7 @@ export function buildMeterChartConfig({
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.BAR,
isTimeAxis: true,
});
builder.addAxis({
@@ -82,7 +81,6 @@ export function buildMeterChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {

View File

@@ -1521,9 +1521,9 @@ const onboardingConfigWithLinks = [
},
{
dataSource: 'nginx-tracing',
label: 'Nginx - Tracing',
label: 'Nginx - OpenTelemetry',
imgUrl: nginxUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
@@ -1626,7 +1626,7 @@ const onboardingConfigWithLinks = [
dataSource: 'cloudflare-workers',
label: 'Cloudflare Workers',
imgUrl: cloudflareUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs'],
module: 'apm',
relatedSearchKeywords: [
'cloudflare',
@@ -5346,13 +5346,17 @@ const onboardingConfigWithLinks = [
dataSource: 'temporal',
label: 'Temporal',
imgUrl: temporalUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
'application performance monitoring',
'integrations',
'logs',
'metrics',
'temporal',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5478,7 +5482,7 @@ const onboardingConfigWithLinks = [
dataSource: 'dbos',
label: 'DBOS',
imgUrl: dbosUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs'],
module: 'apm',
relatedSearchKeywords: [
'database oriented',
@@ -6622,7 +6626,7 @@ const onboardingConfigWithLinks = [
dataSource: 'opentelemetry-ebpf',
label: 'OpenTelemetry eBPF (OBI)',
imgUrl: opentelemetryUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'auto instrumentation',

View File

@@ -13,11 +13,13 @@ import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import APIError from 'types/api/error';
import { installTranslationResilience } from 'translation-resilience';
import './ReactI18';
import 'styles.scss';
installTranslationResilience();
configureOverlayScrollbars();
const queryClient = new QueryClient({

View File

@@ -0,0 +1,55 @@
import { ILog } from 'types/api/logs/log';
import { getLogFieldValue } from './flatLogData';
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
describe('getLogFieldValue', () => {
it('resolves a nested body field by dotted key when use_json_body is on', () => {
const log = asLog({ body: { a: { b: { c: 'deep' } } } });
expect(getLogFieldValue(log, 'a.b.c', true)).toBe('deep');
});
it('ignores body when use_json_body is off', () => {
const log = asLog({ body: { a: { b: { c: 'deep' } } } });
expect(getLogFieldValue(log, 'a.b.c', false)).toBeUndefined();
});
it('ignores a stringified body even when use_json_body is on', () => {
const log = asLog({ body: '{"a":{"b":1}}' });
expect(getLogFieldValue(log, 'a.b', true)).toBeUndefined();
});
it('prefers the body value over attributes when the key exists in both (body first)', () => {
const log = asLog({
attributes_string: { 'a.b': 'attr' } as never,
body: { a: { b: 'bodyval' } },
});
expect(getLogFieldValue(log, 'a.b', true)).toBe('bodyval');
});
it('falls back to attributes when the key is not in the body', () => {
const log = asLog({
attributes_string: { 'x.y': 'attr' } as never,
body: { other: 1 },
});
expect(getLogFieldValue(log, 'x.y', true)).toBe('attr');
});
it('preserves falsy body values (0, false, empty string)', () => {
const log = asLog({ body: { n: 0, flag: false, s: '' } });
expect(getLogFieldValue(log, 'n', true)).toBe(0);
expect(getLogFieldValue(log, 'flag', true)).toBe(false);
expect(getLogFieldValue(log, 's', true)).toBe('');
});
it('returns undefined when the body path is missing', () => {
const log = asLog({ body: { x: 1 } });
expect(getLogFieldValue(log, 'nope', true)).toBeUndefined();
});
it('returns undefined when a mid path segment is not an object', () => {
const log = asLog({ body: { a: { b: 'leaf' } } });
expect(getLogFieldValue(log, 'a.b.c', true)).toBeUndefined();
});
});

View File

@@ -1,5 +1,5 @@
import { defaultTo } from 'lodash-es';
import { ILog } from 'types/api/logs/log';
import { ILog, ILogBody } from 'types/api/logs/log';
export function FlatLogData(log: ILog): Record<string, string> {
const flattenLogObject: Record<string, string> = {};
@@ -15,3 +15,29 @@ export function FlatLogData(log: ILog): Record<string, string> {
});
return flattenLogObject;
}
function getBodyFieldValue(body: ILogBody, key: string): unknown {
return key.split('.').reduce<unknown>((acc, segment) => {
if (acc && typeof acc === 'object' && !Array.isArray(acc)) {
return (acc as Record<string, unknown>)[segment];
}
return undefined;
}, body);
}
// Resolve one field for the logs table. A JSON body is checked first (use_json_body
// only), splitting the key on `.`; otherwise fall back to FlatLogData
// (attributes/resources/scope/top-level).
export function getLogFieldValue(
log: ILog,
fieldName: string,
isBodyJsonEnabled: boolean,
): unknown {
if (isBodyJsonEnabled && log.body && typeof log.body === 'object') {
const bodyValue = getBodyFieldValue(log.body, fieldName);
if (bodyValue !== undefined) {
return bodyValue;
}
}
return FlatLogData(log)[fieldName];
}

View File

@@ -34,7 +34,7 @@ describe('normalizeFilterExpression', () => {
);
});
it('lowercases HAS / HASANY / HASALL / HASTOKEN function names', () => {
it('lowercases HAS / HASANY / HASALL / HASTOKEN / SEARCH function names', () => {
expect(normalizeFilterExpression('HAS(tags, "x")')).toBe(
normalizeFilterExpression('has(tags, "x")'),
);
@@ -47,6 +47,9 @@ describe('normalizeFilterExpression', () => {
expect(normalizeFilterExpression('HASTOKEN(msg, "err")')).toBe(
normalizeFilterExpression('hasToken(msg, "err")'),
);
expect(normalizeFilterExpression('SEARCH("err")')).toBe(
normalizeFilterExpression('search("err")'),
);
});
it('lowercases TRUE / FALSE boolean literals', () => {

View File

@@ -1,5 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
@@ -7,11 +6,6 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
import { buildYAxisSizeCalculator } from '../utils/axis';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
@@ -67,12 +61,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { panelType } = this.props;
const { isTimeAxis } = this.props;
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}

View File

@@ -42,7 +42,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
logBase = 10,
padMinBy = 0,
padMaxBy = 0.05,
useExactTimeRange = false,
} = this.props;
// Special handling for time scales (X axis)
@@ -59,20 +58,14 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Align max time to "endTime - 1 minute", rounded down to minute precision
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
if (!useExactTimeRange) {
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
// Trimming past min inverts the range, which uPlot draws as an empty plot.
if (unixTimestampSeconds > minTime) {
maxTime = unixTimestampSeconds;
}
}
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
maxTime = unixTimestampSeconds;
return {
[scaleKey]: {

View File

@@ -1,5 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import type uPlot from 'uplot';
@@ -137,11 +136,11 @@ describe('UPlotAxisBuilder', () => {
});
});
it('uses time-based X-axis values formatter for time-series like panels', () => {
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
}),
);
@@ -150,11 +149,11 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBe(uPlotXAxisValuesFormat);
});
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
it('does not attach X-axis datetime formatter for a non-time axis', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
isTimeAxis: false,
}),
);
@@ -290,22 +289,9 @@ describe('UPlotAxisBuilder', () => {
expect(config.space).toBe(50);
});
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
const barBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.BAR,
}),
);
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
const timeSeriesBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
}),
);
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
it('omits the X-axis datetime formatter when no time axis is declared', () => {
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
expect(builder.getConfig().values).toBeUndefined();
});
it('should return the existing size when cycleNum > 1', () => {

View File

@@ -79,44 +79,6 @@ describe('UPlotScaleBuilder', () => {
expect(resolvedMax).toBe(expectedMax);
});
it('plots min/max as given when useExactTimeRange is set', () => {
const min = 1_700_000_000;
const max = 1_700_000_630;
const builder = new UPlotScaleBuilder(
createScaleProps({
scaleKey: 'x',
time: true,
min,
max,
useExactTimeRange: true,
}),
);
const config = builder.getConfig();
expect(config.x.range).toStrictEqual([min, max]);
});
it('keeps the requested end when the window is shorter than the trim', () => {
// 23 second window: trimming a minute off the end would put max before min.
const min = 1_786_527_160;
const max = 1_786_527_183;
const builder = new UPlotScaleBuilder(
createScaleProps({
scaleKey: 'x',
time: true,
min,
max,
}),
);
const config = builder.getConfig();
expect(config.x.range).toStrictEqual([min, max]);
});
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
getFallbackMinMaxSpy.mockReturnValue({
fallbackMin: 100,

View File

@@ -1,5 +1,4 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
@@ -70,7 +69,12 @@ export interface AxisProps {
isDarkMode?: boolean;
isLogScale?: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
* (histogram) leaves it off.
*/
isTimeAxis?: boolean;
decimalPrecision?: PrecisionOption;
}
@@ -97,8 +101,6 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Plots a time scale's `min`/`max` as given, skipping the trim below. */
useExactTimeRange?: boolean;
}
export enum DisconnectedValuesMode {

View File

@@ -13,7 +13,6 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/ClickHouse';
import PromQLQueryContainer from 'container/NewWidget/LeftContainer/QuerySection/QueryBuilder/promQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -26,6 +25,7 @@ import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import { getPanelDefinition } from '../../Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -64,8 +64,10 @@ function PanelEditorQueryBuilder({
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const { listView, traceOperator } = getPanelDefinition(panelKind).query;
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -112,9 +114,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
showTraceOperator={traceOperator}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
isListViewPanel={listView}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery

View File

@@ -1,26 +1,26 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
import { EQueryType } from 'types/common/dashboard';
interface PlotTagProps {
/** Authoring mode of the panel's query; undefined when no query exists yet. */
queryType: EQueryType | undefined;
panelType: PANEL_TYPES;
/** Kind is authored as a list view — nothing is plotted, so the chip has nothing to say. */
isListView: boolean;
className?: string;
}
/**
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
* PlotTag (duplicated per the split policy). Hidden for list views and before a
* query exists, where the mode is irrelevant.
*/
function PlotTag({
queryType,
panelType,
isListView,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
if (queryType === undefined || isListView) {
return null;
}

View File

@@ -7,7 +7,6 @@ import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSection
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -72,7 +71,6 @@ function PreviewPane({
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
// Search term is ephemeral preview state, threaded to header + renderer but
@@ -86,7 +84,7 @@ function PreviewPane({
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
isListView={panelDefinition.query.listView}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>

View File

@@ -1,30 +1,22 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from '../PlotTag';
describe('PlotTag', () => {
it('renders the resolved query mode', () => {
render(
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
);
render(<PlotTag queryType={EQueryType.PROM} isListView={false} />);
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
it('renders nothing when there is no query yet', () => {
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
render(<PlotTag queryType={undefined} isListView={false} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
it('renders nothing for a list view (query mode is irrelevant)', () => {
render(<PlotTag queryType={EQueryType.QUERY_BUILDER} isListView />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -4,7 +4,10 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
@@ -91,8 +94,9 @@ export function usePanelEditSession({
const query = usePanelQuery({
panel: draft,
panelId,
queryCapabilities: panelDefinition.query,
time,
enabled: !!panelDefinition,
enabled: isPanelKindSupported(panelKind),
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({

View File

@@ -6,7 +6,7 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
@@ -19,6 +19,7 @@ import type {
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import { getPanelDefinition } from '../../Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -144,11 +145,10 @@ export function usePanelTypeSwitch({
{ ...query, queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
// Match a fresh list view's default order so the builder's Order By isn't empty.
const nextQuery = getPanelDefinition(newKind).query.listView
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;

View File

@@ -1,7 +1,14 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
@@ -15,6 +22,7 @@ import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
@@ -37,9 +45,131 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/ListPanel': [logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
'signoz/TimeSeriesPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
// Bar bins client-side, so it asks for a widened step interval over a raw series.
'signoz/BarChartPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
'signoz/HistogramPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
'signoz/NumberPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
'signoz/PieChartPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
// Only Table asks the server to transpose its scalar result into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
listView: true,
traceOperator: false,
},
};
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getPanelDefinition(kind).query).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
});
// A dashboard spec written by a newer SigNoz can name a kind this build has no
// definition for. The registry answers with UNSUPPORTED_PANEL rather than nothing, so
// every guard below reads it without first proving a definition exists.
describe('a kind this build cannot render', () => {
const unknownKind = 'signoz/SomeFutureKindPanel' as PanelKind;
it('is not reported as supported', () => {
expect(isPanelKindSupported(unknownKind)).toBe(false);
expect(isPanelKindSupported('signoz/TimeSeriesPanel')).toBe(true);
});
it('still resolves to a definition', () => {
expect(getPanelDefinition(unknownKind)).toBe(UNSUPPORTED_PANEL);
});
it('declares nothing, so it is never offered as authorable', () => {
expect(getSupportedSignals(unknownKind)).toStrictEqual([]);
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
expect(isSignalSupported(unknownKind, logs)).toBe(false);
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
});
it('offers no actions', () => {
expect(getPanelDefinition(unknownKind).actions).toStrictEqual(
NO_PANEL_ACTIONS,
);
expect(NO_PANEL_ACTIONS.view).toBe(false);
expect(NO_PANEL_ACTIONS.edit).toBe(false);
expect(NO_PANEL_ACTIONS.drilldown).toBe(false);
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const { query } = getPanelDefinition(unknownKind);
expect(query.requestType).toBe(time_series);
expect(query.serverPaginated).toBe(false);
expect(query.formatTableResultForUI).toBe(false);
});
});
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(

View File

@@ -53,9 +53,10 @@ function NoData({
return <PanelLoader />;
}
const panelType = panel
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
: undefined;
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
const panelKind = panel?.spec.plugin.kind;
const panelType = panelKind ? PANEL_KIND_TO_PANEL_TYPE[panelKind] : undefined;
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel
@@ -65,6 +66,7 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'extendTime',
panelType,
panelKind,
});
activeExtend.extend();
},
@@ -79,6 +81,7 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'retry',
panelType,
panelKind,
});
onRetry();
},

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
@@ -20,6 +23,17 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).
query: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,6 +1,5 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
@@ -48,7 +47,7 @@ export function buildBarChartConfig({
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isTimeAxis: true,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
@@ -20,6 +23,17 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.
query: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,6 +1,5 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
@@ -44,7 +43,7 @@ export function buildHistogramConfig({
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isTimeAxis: false,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
@@ -30,6 +33,17 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
},
},
sections,
// The only kind reading raw rows: they page server-side, and the sort needs a
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
query: {
requestType: Querybuildertypesv5RequestTypeDTO.raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
listView: true,
traceOperator: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
@@ -20,6 +23,15 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
@@ -16,6 +19,15 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
@@ -16,6 +19,16 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -1,7 +1,10 @@
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
@@ -20,6 +23,15 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
query: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,6 +1,5 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import {
buildBaseConfig,
@@ -66,7 +65,7 @@ export function buildTimeSeriesConfig({
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
isDarkMode,
timezone,
panelMode,

View File

@@ -0,0 +1,26 @@
import { CircleHelp } from '@signozhq/icons';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import PanelStyles from '../../panel.module.scss';
/**
* Body for a panel whose kind this build has no renderer for — a spec written by a newer
* SigNoz names a visualization that didn't exist when this client shipped. Says so in
* place of the chart, so the panel keeps its slot in the layout instead of leaving a hole.
*/
function UnsupportedPanelRenderer(): JSX.Element {
return (
<div
data-testid="unsupported-panel-renderer"
className={PanelStyles.panelContainer}
>
<PanelMessage
icon={<CircleHelp size={18} />}
title="Unsupported panel type"
description="This panel was built with a newer version of SigNoz. Upgrade to view it."
/>
</div>
);
}
export default UnsupportedPanelRenderer;

View File

@@ -0,0 +1,36 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import Renderer from './Renderer';
/**
* Stand-in definition for a kind that isn't in the registry, so `getPanelDefinition`
* always resolves and no caller has to branch on a missing one. It declares nothing: no
* signals, no query types, no config sections and no actions — an unknown kind can't be
* queried, configured or acted on, only shown as unsupported.
*
* `kind` carries a sentinel that no API enum value can collide with; the cast is the one
* place this definition steps outside `PanelKind`.
*/
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
Renderer,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],
queryBuilderFields: {},
query: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: false,
},
actions: NO_PANEL_ACTIONS,
};

View File

@@ -5,6 +5,7 @@ import { definition as PieChart } from './kinds/PieChartPanel/definition';
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelRegistry,
RenderablePanelDefinition,
@@ -22,8 +23,24 @@ export const PANELS: PanelRegistry = {
[List.kind]: List,
};
/**
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
* but a dashboard spec written by a newer SigNoz can name one this client has never heard
* of — so ask before doing work on a panel's behalf, such as fetching its data.
*/
export function isPanelKindSupported(kind: PanelKind): boolean {
return kind in PANELS;
}
/**
* The definition for a kind — always one. An unregistered kind resolves to
* {@link UNSUPPORTED_PANEL}, which declares no capabilities and renders as unsupported, so
* callers read a definition's fields without first proving it exists.
*/
export function getPanelDefinition(kind: PanelKind): RenderablePanelDefinition {
// Single intentional cast widening the per-kind Renderer to the kind-agnostic
// prop surface (a per-kind renderer can't be statically validated against the union).
return PANELS[kind] as RenderablePanelDefinition;
return (
(PANELS[kind] as RenderablePanelDefinition | undefined) ?? UNSUPPORTED_PANEL
);
}

View File

@@ -1,4 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
/**
@@ -18,3 +21,37 @@ export type FilterConfigsPartial = NonNullable<
export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code
* has to translate a panel kind into the legacy `PANEL_TYPES` enum to answer these.
*/
export interface PanelQueryCapabilities {
/** V5 request type the panel's data comes back as. */
requestType: Querybuildertypesv5RequestTypeDTO;
/** Server transposes the scalar result into UI table rows (`formatOptions.formatTableResultForUI`). */
formatTableResultForUI: boolean;
/**
* Widen the step interval to cap how many buckets come back — kinds that bin
* client-side from a raw time series rather than plotting every point.
*/
bucketedStepInterval: boolean;
/**
* Append a deterministic tiebreaker to the query's `order` so offset paging over raw
* rows can't repeat or skip a row when the sort key has duplicates.
*/
orderTiebreaker: boolean;
/**
* Rows page server-side via `offset`/`limit`. AND-ed at the call site with "the query
* carries no explicit limit" — an explicit limit means the user asked for a fixed set.
*/
serverPaginated: boolean;
/**
* Authored as a list view: the query builder drops its aggregation controls, and the
* editor preview hides the plot-mode chip because nothing is plotted.
*/
listView: boolean;
/** Query builder offers a trace operator alongside the builder queries. */
traceOperator: boolean;
}

View File

@@ -5,7 +5,10 @@ import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
import type { PanelKind } from './panelKind';
import type { QueryBuilderFieldRule } from './panelCapabilities';
import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
@@ -39,6 +42,24 @@ export interface PanelActionCapabilities {
drilldown: boolean;
}
/**
* No actions at all — for a kind this build can't render, where every action would act on
* a panel body that isn't there. See `UNSUPPORTED_PANEL`.
*/
export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
view: false,
edit: false,
clone: false,
download: {
[DownloadFormat.CSV]: false,
[DownloadFormat.PNG]: false,
[DownloadFormat.SVG]: false,
},
createAlert: false,
search: false,
drilldown: false,
};
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
@@ -50,6 +71,8 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
supportedQueryTypes: EQueryType[];
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
query: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}

View File

@@ -1,8 +1,31 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from '../../types/panelCapabilities';
import { buildDefaultQueries } from '../buildDefaultQueries';
// What a plotted kind and a list-view kind declare. Passed in rather than resolved from
// the registry, which would pull every panel renderer into this suite.
const PLOTTED_CAPS: PanelQueryCapabilities = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
};
const LIST_CAPS: PanelQueryCapabilities = {
...PLOTTED_CAPS,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
listView: true,
traceOperator: false,
};
describe('buildDefaultQueries', () => {
it('seeds a List panel with a runnable logs query ordered by timestamp desc', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
it('seeds a list view with a runnable logs query ordered by timestamp desc', () => {
const queries = buildDefaultQueries('signoz/ListPanel', LIST_CAPS);
expect(queries).toHaveLength(1);
// orderBy timestamp desc must survive serialization so the preview opens
@@ -13,16 +36,20 @@ describe('buildDefaultQueries', () => {
expect(serialized.toLowerCase()).toContain('logs');
});
it('seeds a List panel without a limit so it pages server-side by default', () => {
const queries = buildDefaultQueries('signoz/ListPanel');
it('seeds a list view without a limit so it pages server-side by default', () => {
const queries = buildDefaultQueries('signoz/ListPanel', LIST_CAPS);
// A limit would make usePanelQuery treat the panel as a static, unpaged list.
const spec = queries[0].spec.plugin.spec as { limit?: number };
expect(spec.limit).toBeUndefined();
});
it('seeds no query for non-List kinds (they seed from the builder)', () => {
expect(buildDefaultQueries('signoz/TimeSeriesPanel')).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel')).toStrictEqual([]);
it('seeds no query for plotted kinds (they seed from the builder)', () => {
expect(
buildDefaultQueries('signoz/TimeSeriesPanel', PLOTTED_CAPS),
).toStrictEqual([]);
expect(buildDefaultQueries('signoz/NumberPanel', PLOTTED_CAPS)).toStrictEqual(
[],
);
});
});

View File

@@ -3,7 +3,6 @@ import type {
DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import onClickPlugin, {
OnClickPluginOpts,
@@ -26,7 +25,11 @@ import {
*/
export interface BuildBaseConfigArgs {
panelId: string;
panelType: PANEL_TYPES;
/**
* X axis plots timestamps, so its ticks format as dates/times. Each kind states this
* for itself — a bucketed x axis (histogram) passes false.
*/
isTimeAxis: boolean;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
@@ -63,7 +66,7 @@ export interface BuildBaseConfigArgs {
*/
export function buildBaseConfig({
panelId,
panelType,
isTimeAxis,
isDarkMode,
timezone,
panelMode,
@@ -133,7 +136,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
panelType,
isTimeAxis,
});
builder.addAxis({
@@ -143,7 +146,6 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -1,14 +1,19 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { listViewInitialLogQuery, PANEL_TYPES } from 'constants/queryBuilder';
import { listViewInitialLogQuery } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
/** Seed query for a new panel. Only List needs one (logs, timestamp desc) so its
/** Seed query for a new panel. Only a list view needs one (logs, timestamp desc) so its
* preview runs on open; other kinds start empty and seed from the builder. */
export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
if (PANEL_KIND_TO_PANEL_TYPE[kind] === PANEL_TYPES.LIST) {
return toPerses(listViewInitialLogQuery, PANEL_TYPES.LIST);
export function buildDefaultQueries(
kind: PanelKind,
queryCapabilities: PanelQueryCapabilities,
): DashboardtypesQueryDTO[] {
if (!queryCapabilities.listView) {
return [];
}
return [];
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
}

View File

@@ -1,7 +1,10 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
@@ -50,15 +53,17 @@ function Panel({
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = !!panelDefinition?.actions.search;
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
// Lazy: fetch only once on screen (undefined → visible) and a renderer exists.
enabled: !!panelDefinition && isVisible !== false,
queryCapabilities: panelDefinition.query,
// Lazy: fetch only once on screen (undefined → visible), and never for a kind
// this build can't render — the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && isVisible !== false,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
@@ -85,25 +90,23 @@ function Panel({
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
{panelDefinition && (
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
)}
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);

View File

@@ -1,64 +0,0 @@
import { type KeyboardEvent, useCallback } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Run/Cancel button state. */
isLoadingQueries: boolean;
/** Run the current query (Run Query button / ⌘↵). */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch. */
onCancelQuery: () => void;
}
/**
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
* is query-builder only, exactly as V1.
*/
function ViewPanelQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
}: ViewPanelQueryBuilderProps): JSX.Element {
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onStageRunQuery();
}
},
[onStageRunQuery],
);
return (
<div
className={styles.queryBuilder}
data-testid="view-panel-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<QueryBuilderV2
panelType={panelType}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
/>
<div className={styles.queryBuilderToolbar}>
<RightToolbarActions
handleCancelQuery={onCancelQuery}
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
/>
</div>
</div>
);
}
export default ViewPanelQueryBuilder;

View File

@@ -148,7 +148,9 @@ describe('useCreateAlertFromPanel', () => {
expect(mockBuildQueryRangeRequest).toHaveBeenCalledWith(
expect.objectContaining({
queries: panel.spec.queries,
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: expect.objectContaining({
requestType: 'time_series',
}),
variables: { service: { type: 'query', value: 'checkout' } },
}),
);

View File

@@ -81,6 +81,7 @@ export function useClonePanel({
void logEvent(DashboardDetailEvents.PanelAction, {
action: 'clone',
panelType: PANEL_KIND_TO_PANEL_TYPE[source.panel.spec.plugin.kind],
panelKind: source.panel.spec.plugin.kind,
panelId,
dashboardId,
});

View File

@@ -7,6 +7,7 @@ import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
@@ -44,11 +45,15 @@ export function useCreateAlertFromPanel(): (
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const panelKind = panel.spec.plugin.kind;
// Alerts are a V1 surface: the query pivots through the V1 `Query` shape and the
// URL carries a legacy panel type, so this flow keeps translating.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType,
panelKind,
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
@@ -62,7 +67,7 @@ export function useCreateAlertFromPanel(): (
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
panelType,
queryCapabilities: getPanelDefinition(panelKind).query,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,

View File

@@ -53,6 +53,7 @@ export function useDeletePanel({
panelType: removed?.panel
? PANEL_KIND_TO_PANEL_TYPE[removed.panel.spec.plugin.kind]
: undefined,
panelKind: removed?.panel?.spec.plugin.kind,
panelId,
dashboardId,
});

View File

@@ -43,6 +43,7 @@ export function useDownloadPanelCsv({
void logEvent(DashboardDetailEvents.PanelExported, {
format: 'csv',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
panelKind: panel.spec.plugin.kind,
});
}, [canDownloadCsv, fileName, panel, data]);
}

View File

@@ -128,11 +128,14 @@ export function useDrilldown(
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
void logEvent(DashboardDetailEvents.DrilldownOpened, { panelType });
void logEvent(DashboardDetailEvents.DrilldownOpened, {
panelType,
panelKind: kind,
});
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick, panelType],
[onClick, panelType, kind],
);
const handleClose = useCallback((): void => {
@@ -176,7 +179,8 @@ export function useDrilldown(
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelType,
panelKind: kind,
queryCapabilities: getPanelDefinition(kind).query,
v1Query,
enabled: showAggregateMenu,
});

View File

@@ -74,6 +74,7 @@ export function useMovePanelToSection({
panelType: moved.panel
? PANEL_KIND_TO_PANEL_TYPE[moved.panel.spec.plugin.kind]
: undefined,
panelKind: moved.panel?.spec.plugin.kind,
panelId,
dashboardId,
});

View File

@@ -3,7 +3,11 @@ import { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
@@ -15,7 +19,9 @@ import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
panelKind: PanelKind;
/** The panel kind's declared query capabilities — shapes the substitution request. */
queryCapabilities: PanelQueryCapabilities;
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
v1Query: Query;
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
@@ -38,7 +44,8 @@ interface UseResolvedDrilldownQueryResult {
*/
export function useResolvedDrilldownQuery({
queries,
panelType,
panelKind,
queryCapabilities,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
@@ -60,7 +67,7 @@ export function useResolvedDrilldownQuery({
substituteVars({
data: buildQueryRangeRequest({
queries,
panelType,
queryCapabilities,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
@@ -70,7 +77,7 @@ export function useResolvedDrilldownQuery({
enabled,
hasVariables,
queries,
panelType,
queryCapabilities,
minTime,
maxTime,
variables,
@@ -81,8 +88,13 @@ export function useResolvedDrilldownQuery({
if (!hasVariables || !data) {
return v1Query;
}
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
// View-in-X navigates to a V1 explorer, so the resolved query crosses back into the
// V1 `Query` shape — the one place this hook still needs a legacy panel type.
return envelopesToQuery(
data.data.compositeQuery?.queries ?? [],
PANEL_KIND_TO_PANEL_TYPE[panelKind],
);
}, [hasVariables, data, v1Query, panelKind]);
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,7 +1,11 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -54,6 +58,27 @@ function panelWith(
} as unknown as DashboardtypesPanelDTO;
}
// The capability blocks TimeSeries and List declare. Passed in rather than resolved from
// the registry: the hook takes them as input, and importing the registry here would pull
// every panel renderer (and the app's API client) into this suite.
const TIME_SERIES_CAPS: PanelQueryCapabilities = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
};
const LIST_CAPS: PanelQueryCapabilities = {
...TIME_SERIES_CAPS,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
listView: true,
traceOperator: false,
};
function builderPanel(): DashboardtypesPanelDTO {
return panelWith('signoz/TimeSeriesPanel', {
name: 'A',
@@ -100,7 +125,13 @@ beforeEach(() => {
describe('usePanelQuery', () => {
it('builds the generated V5 request DTO directly from panel.spec.queries', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.schemaVersion).toBe('v1');
expect(requestPayload.compositeQuery.queries).toStrictEqual([
@@ -112,30 +143,30 @@ describe('usePanelQuery', () => {
});
it('converts redux nanosecond time to epoch ms on the request', () => {
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.start).toBe(1_000_000_000);
expect(requestPayload.end).toBe(2_000_000_000);
});
it.each([
['signoz/TimeSeriesPanel', 'time_series'],
['signoz/ListPanel', 'raw'],
// HISTOGRAM and BAR panels bin/derive from raw time-series data
// client-side, so the backend must receive `time_series` (V1 parity).
['signoz/HistogramPanel', 'time_series'],
['signoz/BarChartPanel', 'time_series'],
['signoz/NumberPanel', 'scalar'],
['signoz/PieChartPanel', 'scalar'],
])('%s panel sends requestType=%s', (panelKind, requestType) => {
// Which requestType each kind declares is asserted in
// Panels/__tests__/capabilities.test.ts; here it only has to reach the request.
it('sends the requestType from the declared query capabilities', () => {
renderHook(() =>
usePanelQuery({
panel: panelWith(panelKind, { name: 'A', signal: 'logs' }),
panel: panelWith('signoz/ListPanel', { name: 'A', signal: 'logs' }),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
const [{ requestPayload }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(requestPayload.requestType).toBe(requestType);
expect(requestPayload.requestType).toBe('raw');
});
it('exposes the raw V5 response, request payload, and legend map on data', () => {
@@ -148,7 +179,11 @@ describe('usePanelQuery', () => {
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
expect(result.current.data.response).toBe(v5Response);
@@ -158,7 +193,11 @@ describe('usePanelQuery', () => {
it('exposes an undefined response before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
expect(result.current.data.response).toBeUndefined();
});
@@ -171,7 +210,11 @@ describe('usePanelQuery', () => {
error: new Error('boom'),
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
expect(result.current.error?.message).toBe('boom');
});
@@ -186,7 +229,11 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(true);
@@ -200,7 +247,11 @@ describe('usePanelQuery', () => {
error: null,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
expect(result.current.isLoading).toBe(true);
});
@@ -213,14 +264,23 @@ describe('usePanelQuery', () => {
error: undefined,
});
const { result } = renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1' }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
expect(result.current.error).toBeNull();
});
it('passes enabled=false to the fetch hook when the caller disables it', () => {
renderHook(() =>
usePanelQuery({ panel: builderPanel(), panelId: 'p1', enabled: false }),
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
enabled: false,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -228,7 +288,12 @@ describe('usePanelQuery', () => {
it('auto-disables the fetch when the panel has no queries (even with enabled=true)', () => {
renderHook(() =>
usePanelQuery({ panel: emptyPanel(), panelId: 'p1', enabled: true }),
usePanelQuery({
panel: emptyPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
enabled: true,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(enabled).toBe(false);
@@ -243,6 +308,7 @@ describe('usePanelQuery', () => {
aggregations: [{}],
}),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
const [{ enabled }] = mockUseGetQueryRangeV5.mock.calls[0];
@@ -251,7 +317,9 @@ describe('usePanelQuery', () => {
it('composes a react-query cache key that includes panelId, time range, kind, and queries', () => {
const panel = builderPanel();
renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
renderHook(() =>
usePanelQuery({ panel, panelId: 'p1', queryCapabilities: TIME_SERIES_CAPS }),
);
const [{ queryKey }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(queryKey).toStrictEqual(
expect.arrayContaining([
@@ -270,6 +338,7 @@ describe('usePanelQuery', () => {
renderHook(() =>
usePanelQuery({
panel,
queryCapabilities: TIME_SERIES_CAPS,
panelId: 'p1',
time: { startMs: 1_700_000_000_000, endMs: 1_700_000_600_000 },
}),
@@ -296,6 +365,7 @@ describe('usePanelQuery', () => {
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
time: { startMs: 1_700_000_000_000.546, endMs: 1_700_000_600_000.999 },
}),
);
@@ -316,7 +386,11 @@ describe('usePanelQuery', () => {
it('exposes server paging at the default page size when the query has no limit', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.pageSize).toBe(25);
@@ -327,20 +401,34 @@ describe('usePanelQuery', () => {
it('disables the server pager when the query has an explicit limit (V1 parity)', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({ limit: 100 }), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({ limit: 100 }),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(result.current.pagination).toBeUndefined();
});
it('keeps previous data while paging so the table/pager stay mounted on page change', () => {
renderHook(() => usePanelQuery({ panel: listPanel({}), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
const [{ keepPreviousData }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(keepPreviousData).toBe(true);
});
it('changes the page size (and re-requests with the new limit) via setPageSize', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
act(() => result.current.pagination?.setPageSize(50));
@@ -380,7 +468,11 @@ describe('usePanelQuery', () => {
it('starts on page 0 with no prev/next and does not throw before data arrives', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(result.current.pagination?.pageIndex).toBe(0);
expect(result.current.pagination?.canPrev).toBe(false);
@@ -392,21 +484,33 @@ describe('usePanelQuery', () => {
// window/cursor path), so a full page is the has-more signal.
withResponse(rawResponse(25));
const fullPage = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(fullPage.result.current.pagination?.canNext).toBe(true);
// Partial page, no cursor → the last page.
withResponse(rawResponse(3));
const partialPage = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(partialPage.result.current.pagination?.canNext).toBe(false);
// Cursor present (even on a partial page) → more rows (timestamp window path).
withResponse(rawResponse(3, 'cursor-1'));
const withCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(withCursor.result.current.pagination?.canNext).toBe(true);
});
@@ -416,7 +520,9 @@ describe('usePanelQuery', () => {
// Stable panel reference: a fresh one each render would change the
// `queries` identity and trip the offset-reset effect (real props are stable).
const panel = listPanel({});
const { result } = renderHook(() => usePanelQuery({ panel, panelId: 'p1' }));
const { result } = renderHook(() =>
usePanelQuery({ panel, panelId: 'p1', queryCapabilities: LIST_CAPS }),
);
expect(result.current.pagination?.pageIndex).toBe(0);
act(() => result.current.pagination?.goNext());
@@ -428,7 +534,11 @@ describe('usePanelQuery', () => {
it('stays defined and zero-paged for a non-raw (scalar) response', () => {
withResponse({ data: { type: 'scalar', data: { results: [] } } });
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
expect(result.current.pagination).toBeDefined();
expect(result.current.pagination?.canNext).toBe(false);
@@ -437,7 +547,11 @@ describe('usePanelQuery', () => {
it('ignores a non-positive page size so paging never goes invalid', () => {
const { result } = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
usePanelQuery({
panel: listPanel({}),
panelId: 'p1',
queryCapabilities: LIST_CAPS,
}),
);
act(() => result.current.pagination?.setPageSize(0));
expect(result.current.pagination?.pageSize).toBe(25);
@@ -456,14 +570,26 @@ describe('usePanelQuery', () => {
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
withAutoRefreshDisabled(true);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
});
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
withAutoRefreshDisabled(false);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
renderHook(() =>
usePanelQuery({
panel: builderPanel(),
panelId: 'p1',
queryCapabilities: TIME_SERIES_CAPS,
}),
);
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});

View File

@@ -3,7 +3,6 @@ import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
@@ -24,7 +23,7 @@ import {
queryReferencesAnyVariable,
} from '../queryV5/getReferencedVariables';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
@@ -38,6 +37,8 @@ const DEFAULT_LIST_PAGE_SIZE = 25;
export interface UsePanelQueryArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The panel kind's declared query capabilities — `panelDefinition.query`, or `DEFAULT_QUERY_CAPABILITIES` for a kind the registry doesn't resolve. */
queryCapabilities: PanelQueryCapabilities;
/**
* Gate the fetch (default true). PanelV2 sets false for unregistered kinds to skip a wasted
* call. The hook also auto-disables internally when the panel has no runnable queries.
@@ -85,21 +86,20 @@ export interface UsePanelQueryResult {
export function usePanelQuery({
panel,
panelId,
queryCapabilities,
enabled = true,
time,
}: UsePanelQueryArgs): UsePanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const queries = panel.spec.queries;
// V1 parity: a list query with an explicit `limit` shows without a server pager; without
// one it pages server-side at a user-selectable size.
// V1 parity: a query with an explicit `limit` shows without a server pager; without
// one a paging kind fetches server-side at a user-selectable size.
const hasExplicitLimit = useMemo(
() => !!getBuilderQueries(queries)[0]?.limit,
[queries],
);
const isPaginated = panelType === PANEL_TYPES.LIST && !hasExplicitLimit;
const isPaginated = queryCapabilities.serverPaginated && !hasExplicitLimit;
const [pageSize, setPageSize] = useState(DEFAULT_LIST_PAGE_SIZE);
const [offset, setOffset] = useState(0);
@@ -188,7 +188,7 @@ export function usePanelQuery({
() =>
buildQueryRangeRequest({
queries,
panelType,
queryCapabilities,
startMs,
endMs,
fillGaps,
@@ -197,7 +197,7 @@ export function usePanelQuery({
}),
[
queries,
panelType,
queryCapabilities,
startMs,
endMs,
fillGaps,

View File

@@ -1,12 +1,13 @@
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
type DashboardtypesQueryDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
buildQueryRangeRequest,
extractLegendMap,
getBarStepIntervalSeconds,
hasRunnableQueries,
panelTypeToRequestType,
toQueryEnvelopes,
} from '../buildQueryRangeRequest';
@@ -40,20 +41,47 @@ function compositeQuery(
const HOUR_MS = 60 * 60 * 1000;
const START_MS = 1_700_000_000_000;
describe('panelTypeToRequestType', () => {
// Capability blocks matching what each kind declares, so these tests exercise the
// builder's response to the flags rather than the declarations themselves (those are
// asserted against the registry in Panels/__tests__/capabilities.test.ts).
const TIME_SERIES_CAPS = {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
};
const BAR_CAPS = { ...TIME_SERIES_CAPS, bucketedStepInterval: true };
const TABLE_CAPS = {
...TIME_SERIES_CAPS,
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
};
const LIST_CAPS = {
...TIME_SERIES_CAPS,
requestType: Querybuildertypesv5RequestTypeDTO.raw,
orderTiebreaker: true,
serverPaginated: true,
listView: true,
traceOperator: false,
};
describe('requestType', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
Querybuildertypesv5RequestTypeDTO.time_series,
Querybuildertypesv5RequestTypeDTO.scalar,
Querybuildertypesv5RequestTypeDTO.raw,
Querybuildertypesv5RequestTypeDTO.trace,
])('passes %s through from the declared capabilities', (requestType) => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: { ...TIME_SERIES_CAPS, requestType },
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request.requestType).toBe(requestType);
});
});
@@ -135,7 +163,7 @@ describe('buildQueryRangeRequest', () => {
it('assembles the full request DTO', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: TIME_SERIES_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -157,7 +185,7 @@ describe('buildQueryRangeRequest', () => {
it('sets formatTableResultForUI only for TABLE panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TABLE,
queryCapabilities: TABLE_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -167,7 +195,7 @@ describe('buildQueryRangeRequest', () => {
it('passes through fillGaps into formatOptions', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: TIME_SERIES_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
fillGaps: true,
@@ -178,7 +206,7 @@ describe('buildQueryRangeRequest', () => {
it('stamps offset/limit onto builder queries when pagination is given', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
pagination: { offset: 100, limit: 50 },
@@ -198,7 +226,7 @@ describe('buildQueryRangeRequest', () => {
it('defaults a logs list with no order to timestamp desc + id tiebreaker', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -218,7 +246,7 @@ describe('buildQueryRangeRequest', () => {
signal: 'logs',
order: [{ key: { name: 'timestamp' }, direction: 'desc' }],
}),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -238,7 +266,7 @@ describe('buildQueryRangeRequest', () => {
];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'logs', order }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -252,7 +280,7 @@ describe('buildQueryRangeRequest', () => {
const order = [{ key: { name: 'timestamp' }, direction: 'desc' }];
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'traces', order }),
panelType: PANEL_TYPES.LIST,
queryCapabilities: LIST_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -265,7 +293,7 @@ describe('buildQueryRangeRequest', () => {
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
panelType: PANEL_TYPES.BAR,
queryCapabilities: BAR_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -280,7 +308,7 @@ describe('buildQueryRangeRequest', () => {
it('preserves a user-set stepInterval on BAR builder queries', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
panelType: PANEL_TYPES.BAR,
queryCapabilities: BAR_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -293,7 +321,7 @@ describe('buildQueryRangeRequest', () => {
it('does not touch stepInterval for non-BAR panels', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
panelType: PANEL_TYPES.TIME_SERIES,
queryCapabilities: TIME_SERIES_CAPS,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});

View File

@@ -7,7 +7,12 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { envelopesToQuery, fromPerses, toPerses } from '../persesQueryAdapters';
import {
envelopesToQuery,
fromPerses,
panelTypeToRequestType,
toPerses,
} from '../persesQueryAdapters';
/** A bare perses query (single plugin, not wrapped in a CompositeQuery). */
function bareQuery(
@@ -21,6 +26,23 @@ function bareQuery(
}
describe('persesQueryAdapters', () => {
describe('panelTypeToRequestType', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, 'time_series'],
// HISTOGRAM and BAR bin client-side from time-series data; sending
// 'distribution' would return a shape the renderers can't bin.
[PANEL_TYPES.BAR, 'time_series'],
[PANEL_TYPES.HISTOGRAM, 'time_series'],
[PANEL_TYPES.TABLE, 'scalar'],
[PANEL_TYPES.PIE, 'scalar'],
[PANEL_TYPES.VALUE, 'scalar'],
[PANEL_TYPES.LIST, 'raw'],
[PANEL_TYPES.TRACE, 'trace'],
])('%s → %s', (panelType, requestType) => {
expect(panelTypeToRequestType(panelType)).toBe(requestType);
});
});
describe('fromPerses', () => {
it('returns a fresh metrics builder query for an empty panel', () => {
const query = fromPerses([], PANEL_TYPES.TIME_SERIES);

View File

@@ -14,9 +14,9 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
// shared fields are read through this view with a localized cast at the envelope boundary.
@@ -29,31 +29,6 @@ interface QuerySpecView {
order?: Querybuildertypesv5OrderByDTO[];
}
/**
* Maps a V2 panel type to the V5 `requestType`. HISTOGRAM/BAR bin client-side from raw
* time-series, so their request type is `time_series` (V1 parity).
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* Unwraps the perses query into the V5 `compositeQuery.queries` list: a CompositeQuery passes
* through verbatim, bare plugins wrap into one envelope. Top-level Formula/TraceOperator are
@@ -239,7 +214,13 @@ function withPagination(
export interface BuildQueryRangeRequestArgs {
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
/**
* The panel kind's declared query capabilities (`PanelDefinition.query`): request type,
* result formatting, and the step-interval/order treatment. Passed in rather than looked up
* by kind so this stays a leaf of the query layer — the panel registry carries every
* renderer with it, which has no business in the data path.
*/
queryCapabilities: PanelQueryCapabilities;
/** Epoch milliseconds. */
startMs: number;
/** Epoch milliseconds. */
@@ -258,7 +239,12 @@ export interface BuildQueryRangeRequestArgs {
*/
export function buildQueryRangeRequest({
queries,
panelType,
queryCapabilities: {
requestType,
formatTableResultForUI,
bucketedStepInterval,
orderTiebreaker,
},
startMs,
endMs,
fillGaps = false,
@@ -266,10 +252,10 @@ export function buildQueryRangeRequest({
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (panelType === PANEL_TYPES.BAR) {
if (bucketedStepInterval) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
}
if (panelType === PANEL_TYPES.LIST) {
if (orderTiebreaker) {
envelopes = withListOrderTiebreaker(envelopes);
}
if (pagination) {
@@ -280,10 +266,10 @@ export function buildQueryRangeRequest({
schemaVersion: 'v1',
start: startMs,
end: endMs,
requestType: panelTypeToRequestType(panelType),
requestType,
compositeQuery: { queries: envelopes },
formatOptions: {
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
formatTableResultForUI,
fillGaps,
},
variables,

View File

@@ -10,6 +10,7 @@ import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
@@ -20,10 +21,7 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
panelTypeToRequestType,
toQueryEnvelopes,
} from './buildQueryRangeRequest';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
/**
* Adapters between the V2 perses query shape and the V1 `Query` the shared query
@@ -90,6 +88,33 @@ export function deriveQueryType(
return EQueryType.QUERY_BUILDER;
}
/**
* Maps a legacy panel type to the V5 `requestType`. Lives on this side of the V1 boundary
* because only the V1 pivot still speaks `PANEL_TYPES` — V2 panels read `requestType` off
* their kind's declared query capabilities instead. BAR/HISTOGRAM bin client-side from a raw
* time series, so they request `time_series` (V1 parity).
*/
export function panelTypeToRequestType(
panelType: PANEL_TYPES,
): Querybuildertypesv5RequestTypeDTO {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES:
case PANEL_TYPES.BAR:
case PANEL_TYPES.HISTOGRAM:
return Querybuildertypesv5RequestTypeDTO.time_series;
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:
return Querybuildertypesv5RequestTypeDTO.time_series;
}
}
/**
* V5 query-envelope list → V1 `Query`, via `mapQueryDataFromApi`. An empty list opens
* on a fresh metrics builder query. Used by `fromPerses` and by the envelopes a

View File

@@ -62,7 +62,10 @@ export function buildNewPanelSeed(
if (!isExplorerExport || !compositeQuery) {
return {
kind: requestedKind,
queries: buildDefaultQueries(requestedKind),
queries: buildDefaultQueries(
requestedKind,
getPanelDefinition(requestedKind).query,
),
pluginSpec: buildPluginSpec(getPanelDefinition(requestedKind).sections),
};
}
@@ -71,7 +74,10 @@ export function buildNewPanelSeed(
const pluginSpec = buildPluginSpec(getPanelDefinition(kind).sections);
const converted = toPerses(compositeQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
const queries =
converted.length > 0
? converted
: buildDefaultQueries(kind, getPanelDefinition(kind).query);
// Explorers put the single `unit` on the query itself, not the panel spec.
if (compositeQuery.unit && kindSupportsUnit(kind)) {

View File

@@ -40,6 +40,7 @@ function PublicPanel({
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
queryCapabilities: panelDefinition.query,
panelKey,
publicDashboardId,
startMs,

View File

@@ -1,6 +1,9 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getPublicDashboardPanelQueryRangeV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5RequestTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
@@ -42,6 +45,17 @@ const panel = {
const args = {
panel,
// What TimeSeries declares; passed in rather than resolved from the registry, which
// would pull every panel renderer into this suite.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
listView: false,
traceOperator: true,
},
panelKey: 'panel-1',
publicDashboardId: 'pub-1',
startMs: 1000,

View File

@@ -3,10 +3,9 @@ import type {
DashboardtypesPanelDTO,
GetPublicDashboardPanelQueryRangeV2200,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { retryUnlessClientError } from 'pages/DashboardPageV2/DashboardContainer/hooks/useGetQueryRangeV5';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { PanelQueryCapabilities } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelCapabilities';
import {
buildQueryRangeRequest,
extractLegendMap,
@@ -21,6 +20,8 @@ import { useQuery, useQueryClient } from 'react-query';
export interface UsePublicPanelQueryArgs {
panel: DashboardtypesPanelDTO;
/** The panel kind's declared query capabilities — `panelDefinition.query`. */
queryCapabilities: PanelQueryCapabilities;
/** Panel key in `spec.panels` — addresses the panel on the public endpoint. */
panelKey: string;
publicDashboardId: string;
@@ -52,15 +53,13 @@ export interface UsePublicPanelQueryResult {
*/
export function usePublicPanelQuery({
panel,
queryCapabilities,
panelKey,
publicDashboardId,
startMs,
endMs,
enabled = true,
}: UsePublicPanelQueryArgs): UsePublicPanelQueryResult {
const fullKind = panel.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind]) ?? PANEL_TYPES.TIME_SERIES;
const { queries } = panel.spec;
const pluginSpec = panel.spec.plugin.spec;
@@ -77,13 +76,13 @@ export function usePublicPanelQuery({
() =>
buildQueryRangeRequest({
queries,
panelType,
queryCapabilities,
startMs,
endMs,
fillGaps,
variables: {},
}),
[queries, panelType, startMs, endMs, fillGaps],
[queries, queryCapabilities, startMs, endMs, fillGaps],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);

View File

@@ -42,7 +42,7 @@ function SpanPercentilePanel({
selectedTimeRange,
setSelectedTimeRange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
toggleResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
@@ -72,9 +72,7 @@ function SpanPercentilePanel({
variant="link"
color="secondary"
size="icon"
onClick={(): void =>
setShowResourceAttributesSelector(!showResourceAttributesSelector)
}
onClick={toggleResourceAttributesSelector}
prefix={
showResourceAttributesSelector ? <Check size={16} /> : <Plus size={16} />
}

View File

@@ -8,6 +8,11 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import {
TraceDetailEventKeys,
TraceDetailEvents,
} from 'pages/TraceDetailsV3/events';
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
import { SpanV3 } from 'types/api/trace/getTraceV3';
export interface IResourceAttribute {
@@ -37,7 +42,7 @@ export interface UseSpanPercentileReturn {
selectedTimeRange: number;
setSelectedTimeRange: (range: number) => void;
showResourceAttributesSelector: boolean;
setShowResourceAttributesSelector: (show: boolean) => void;
toggleResourceAttributesSelector: () => void;
resourceAttributesSearchQuery: string;
setResourceAttributesSearchQuery: (query: string) => void;
spanResourceAttributes: IResourceAttribute[];
@@ -76,6 +81,8 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
@@ -257,6 +264,12 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
logTraceEvent(TraceDetailEvents.SpanPercentileAttributeChanged, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.ResourceAttributeKey]: key,
[TraceDetailEventKeys.Selected]: isSelected,
});
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
@@ -271,7 +284,7 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
[selectedResourceAttributes, logTraceEvent, selectedSpan.span_id],
);
useEffect(() => {
@@ -293,12 +306,37 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
'ms',
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
const toggleOpen = useCallback(() => {
const nextOpen = !isOpen;
setIsOpen(nextOpen);
logTraceEvent(TraceDetailEvents.SpanPercentileToggled, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.Open]: nextOpen,
[TraceDetailEventKeys.PercentileValue]: percentileValue,
});
}, [isOpen, logTraceEvent, selectedSpan.span_id, percentileValue]);
const handleTimeRangeChange = useCallback((range: number): void => {
setShouldFetchData(true);
setSelectedTimeRange(range);
}, []);
const toggleResourceAttributesSelector = useCallback(() => {
const nextOpen = !showResourceAttributesSelector;
setShowResourceAttributesSelector(nextOpen);
logTraceEvent(TraceDetailEvents.SpanPercentileAttributesSelectorToggled, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.Open]: nextOpen,
});
}, [showResourceAttributesSelector, logTraceEvent, selectedSpan.span_id]);
const handleTimeRangeChange = useCallback(
(range: number): void => {
logTraceEvent(TraceDetailEvents.SpanPercentileTimeRangeChanged, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.From]: selectedTimeRange,
[TraceDetailEventKeys.To]: range,
});
setShouldFetchData(true);
setSelectedTimeRange(range);
},
[logTraceEvent, selectedSpan.span_id, selectedTimeRange],
);
return {
isOpen,
@@ -312,7 +350,7 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
selectedTimeRange,
setSelectedTimeRange: handleTimeRangeChange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
toggleResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,

View File

@@ -8,6 +8,10 @@ export enum TraceDetailEvents {
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
DownloadTriggered = 'Trace Detail: Download triggered',
DownloadCancelled = 'Trace Detail: Download cancelled',
SpanPercentileToggled = 'Trace Detail: Span percentile toggled',
SpanPercentileTimeRangeChanged = 'Trace Detail: Span percentile time range changed',
SpanPercentileAttributesSelectorToggled = 'Trace Detail: Span percentile attributes selector toggled',
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
}
export enum TraceDetailEventKeys {
@@ -36,6 +40,10 @@ export enum TraceDetailEventKeys {
SpanId = 'spanId',
// Download triggered (reuses TotalSpansCount for trace size)
Format = 'format',
// Span percentile (reuses Open, SpanId, From, To)
PercentileValue = 'percentileValue',
ResourceAttributeKey = 'resourceAttributeKey',
Selected = 'selected',
}
export type TraceDetailView = 'v2' | 'v3';

File diff suppressed because one or more lines are too long

View File

@@ -24,12 +24,13 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
'('=1
')'=2
'['=3

File diff suppressed because one or more lines are too long

View File

@@ -24,12 +24,13 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
'('=1
')'=2
'['=3

View File

@@ -1,4 +1,4 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// Generated from FilterQuery.g4 by ANTLR 4.13.2
// noinspection ES6UnusedImports,JSUnusedGlobalSymbols,JSUnusedLocalSymbols
import {
ATN,
@@ -38,12 +38,13 @@ export default class FilterQueryLexer extends Lexer {
public static readonly HAS = 24;
public static readonly HASANY = 25;
public static readonly HASALL = 26;
public static readonly BOOL = 27;
public static readonly NUMBER = 28;
public static readonly QUOTED_TEXT = 29;
public static readonly KEY = 30;
public static readonly WS = 31;
public static readonly FREETEXT = 32;
public static readonly SEARCH = 27;
public static readonly BOOL = 28;
public static readonly NUMBER = 29;
public static readonly QUOTED_TEXT = 30;
public static readonly KEY = 31;
public static readonly WS = 32;
public static readonly FREETEXT = 33;
public static readonly EOF = Token.EOF;
public static readonly channelNames: string[] = [ "DEFAULT_TOKEN_CHANNEL", "HIDDEN" ];
@@ -68,8 +69,9 @@ export default class FilterQueryLexer extends Lexer {
"AND", "OR",
"HASTOKEN",
"HAS", "HASANY",
"HASALL", "BOOL",
"NUMBER", "QUOTED_TEXT",
"HASALL", "SEARCH",
"BOOL", "NUMBER",
"QUOTED_TEXT",
"KEY", "WS",
"FREETEXT" ];
public static readonly modeNames: string[] = [ "DEFAULT_MODE", ];
@@ -78,8 +80,8 @@ export default class FilterQueryLexer extends Lexer {
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS", "REGEXP",
"CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY", "HASALL",
"BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS", "OLD_JSON_BRACKS",
"KEY", "WS", "DIGIT", "FREETEXT",
"SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS",
"OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
];
@@ -100,119 +102,122 @@ export default class FilterQueryLexer extends Lexer {
public get modeNames(): string[] { return FilterQueryLexer.modeNames; }
public static readonly _serializedATN: number[] = [4,0,32,320,6,-1,2,0,
public static readonly _serializedATN: number[] = [4,0,33,329,6,-1,2,0,
7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9,
7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,2,16,7,
16,2,17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,
2,24,7,24,2,25,7,25,2,26,7,26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,1,0,1,0,1,1,1,
1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,89,8,5,1,6,1,6,1,6,1,7,1,7,1,
7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,
1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,
15,1,15,1,15,1,15,1,15,1,15,3,15,132,8,15,1,16,1,16,1,16,1,16,1,16,1,16,
1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,149,8,17,1,18,1,18,1,
18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,1,22,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,24,1,24,1,
24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,3,26,201,8,26,1,27,1,27,1,28,3,28,206,8,28,1,28,4,28,
209,8,28,11,28,12,28,210,1,28,1,28,5,28,215,8,28,10,28,12,28,218,9,28,3,
28,220,8,28,1,28,1,28,3,28,224,8,28,1,28,4,28,227,8,28,11,28,12,28,228,
3,28,231,8,28,1,28,3,28,234,8,28,1,28,1,28,4,28,238,8,28,11,28,12,28,239,
1,28,1,28,3,28,244,8,28,1,28,4,28,247,8,28,11,28,12,28,248,3,28,251,8,28,
3,28,253,8,28,1,29,1,29,1,29,1,29,5,29,259,8,29,10,29,12,29,262,9,29,1,
29,1,29,1,29,1,29,1,29,5,29,269,8,29,10,29,12,29,272,9,29,1,29,3,29,275,
8,29,1,30,1,30,5,30,279,8,30,10,30,12,30,282,9,30,1,31,1,31,1,31,1,32,1,
32,1,32,1,32,1,33,1,33,1,33,1,33,1,33,1,33,1,33,4,33,298,8,33,11,33,12,
33,299,5,33,302,8,33,10,33,12,33,305,9,33,1,34,4,34,308,8,34,11,34,12,34,
309,1,34,1,34,1,35,1,35,1,36,4,36,317,8,36,11,36,12,36,318,0,0,37,1,1,3,
2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,
16,33,17,35,18,37,19,39,20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,
0,57,28,59,29,61,0,63,0,65,0,67,30,69,31,71,0,73,32,1,0,29,2,0,76,76,108,
108,2,0,73,73,105,105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,
98,2,0,84,84,116,116,2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,
120,2,0,83,83,115,115,2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,
112,2,0,67,67,99,99,2,0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,
2,0,72,72,104,104,2,0,89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,
2,0,43,43,45,45,2,0,34,34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,
123,7,0,35,36,45,45,47,58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,
32,1,0,48,57,8,0,9,10,13,13,32,34,39,41,44,44,60,62,91,91,93,93,344,0,1,
1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,
13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,
0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,
35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,
0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,57,1,0,0,0,0,
59,1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,73,1,0,0,0,1,75,1,0,0,0,3,77,1,0,
0,0,5,79,1,0,0,0,7,81,1,0,0,0,9,83,1,0,0,0,11,88,1,0,0,0,13,90,1,0,0,0,
15,93,1,0,0,0,17,96,1,0,0,0,19,98,1,0,0,0,21,101,1,0,0,0,23,103,1,0,0,0,
25,106,1,0,0,0,27,111,1,0,0,0,29,117,1,0,0,0,31,125,1,0,0,0,33,133,1,0,
0,0,35,140,1,0,0,0,37,150,1,0,0,0,39,153,1,0,0,0,41,157,1,0,0,0,43,161,
1,0,0,0,45,164,1,0,0,0,47,173,1,0,0,0,49,177,1,0,0,0,51,184,1,0,0,0,53,
200,1,0,0,0,55,202,1,0,0,0,57,252,1,0,0,0,59,274,1,0,0,0,61,276,1,0,0,0,
63,283,1,0,0,0,65,286,1,0,0,0,67,290,1,0,0,0,69,307,1,0,0,0,71,313,1,0,
0,0,73,316,1,0,0,0,75,76,5,40,0,0,76,2,1,0,0,0,77,78,5,41,0,0,78,4,1,0,
0,0,79,80,5,91,0,0,80,6,1,0,0,0,81,82,5,93,0,0,82,8,1,0,0,0,83,84,5,44,
0,0,84,10,1,0,0,0,85,89,5,61,0,0,86,87,5,61,0,0,87,89,5,61,0,0,88,85,1,
0,0,0,88,86,1,0,0,0,89,12,1,0,0,0,90,91,5,33,0,0,91,92,5,61,0,0,92,14,1,
0,0,0,93,94,5,60,0,0,94,95,5,62,0,0,95,16,1,0,0,0,96,97,5,60,0,0,97,18,
1,0,0,0,98,99,5,60,0,0,99,100,5,61,0,0,100,20,1,0,0,0,101,102,5,62,0,0,
102,22,1,0,0,0,103,104,5,62,0,0,104,105,5,61,0,0,105,24,1,0,0,0,106,107,
7,0,0,0,107,108,7,1,0,0,108,109,7,2,0,0,109,110,7,3,0,0,110,26,1,0,0,0,
111,112,7,1,0,0,112,113,7,0,0,0,113,114,7,1,0,0,114,115,7,2,0,0,115,116,
7,3,0,0,116,28,1,0,0,0,117,118,7,4,0,0,118,119,7,3,0,0,119,120,7,5,0,0,
120,121,7,6,0,0,121,122,7,3,0,0,122,123,7,3,0,0,123,124,7,7,0,0,124,30,
1,0,0,0,125,126,7,3,0,0,126,127,7,8,0,0,127,128,7,1,0,0,128,129,7,9,0,0,
129,131,7,5,0,0,130,132,7,9,0,0,131,130,1,0,0,0,131,132,1,0,0,0,132,32,
1,0,0,0,133,134,7,10,0,0,134,135,7,3,0,0,135,136,7,11,0,0,136,137,7,3,0,
0,137,138,7,8,0,0,138,139,7,12,0,0,139,34,1,0,0,0,140,141,7,13,0,0,141,
142,7,14,0,0,142,143,7,7,0,0,143,144,7,5,0,0,144,145,7,15,0,0,145,146,7,
1,0,0,146,148,7,7,0,0,147,149,7,9,0,0,148,147,1,0,0,0,148,149,1,0,0,0,149,
36,1,0,0,0,150,151,7,1,0,0,151,152,7,7,0,0,152,38,1,0,0,0,153,154,7,7,0,
0,154,155,7,14,0,0,155,156,7,5,0,0,156,40,1,0,0,0,157,158,7,15,0,0,158,
159,7,7,0,0,159,160,7,16,0,0,160,42,1,0,0,0,161,162,7,14,0,0,162,163,7,
10,0,0,163,44,1,0,0,0,164,165,7,17,0,0,165,166,7,15,0,0,166,167,7,9,0,0,
167,168,7,5,0,0,168,169,7,14,0,0,169,170,7,2,0,0,170,171,7,3,0,0,171,172,
7,7,0,0,172,46,1,0,0,0,173,174,7,17,0,0,174,175,7,15,0,0,175,176,7,9,0,
0,176,48,1,0,0,0,177,178,7,17,0,0,178,179,7,15,0,0,179,180,7,9,0,0,180,
181,7,15,0,0,181,182,7,7,0,0,182,183,7,18,0,0,183,50,1,0,0,0,184,185,7,
17,0,0,185,186,7,15,0,0,186,187,7,9,0,0,187,188,7,15,0,0,188,189,7,0,0,
0,189,190,7,0,0,0,190,52,1,0,0,0,191,192,7,5,0,0,192,193,7,10,0,0,193,194,
7,19,0,0,194,201,7,3,0,0,195,196,7,20,0,0,196,197,7,15,0,0,197,198,7,0,
0,0,198,199,7,9,0,0,199,201,7,3,0,0,200,191,1,0,0,0,200,195,1,0,0,0,201,
54,1,0,0,0,202,203,7,21,0,0,203,56,1,0,0,0,204,206,3,55,27,0,205,204,1,
0,0,0,205,206,1,0,0,0,206,208,1,0,0,0,207,209,3,71,35,0,208,207,1,0,0,0,
209,210,1,0,0,0,210,208,1,0,0,0,210,211,1,0,0,0,211,219,1,0,0,0,212,216,
5,46,0,0,213,215,3,71,35,0,214,213,1,0,0,0,215,218,1,0,0,0,216,214,1,0,
0,0,216,217,1,0,0,0,217,220,1,0,0,0,218,216,1,0,0,0,219,212,1,0,0,0,219,
220,1,0,0,0,220,230,1,0,0,0,221,223,7,3,0,0,222,224,3,55,27,0,223,222,1,
0,0,0,223,224,1,0,0,0,224,226,1,0,0,0,225,227,3,71,35,0,226,225,1,0,0,0,
227,228,1,0,0,0,228,226,1,0,0,0,228,229,1,0,0,0,229,231,1,0,0,0,230,221,
1,0,0,0,230,231,1,0,0,0,231,253,1,0,0,0,232,234,3,55,27,0,233,232,1,0,0,
0,233,234,1,0,0,0,234,235,1,0,0,0,235,237,5,46,0,0,236,238,3,71,35,0,237,
236,1,0,0,0,238,239,1,0,0,0,239,237,1,0,0,0,239,240,1,0,0,0,240,250,1,0,
0,0,241,243,7,3,0,0,242,244,3,55,27,0,243,242,1,0,0,0,243,244,1,0,0,0,244,
246,1,0,0,0,245,247,3,71,35,0,246,245,1,0,0,0,247,248,1,0,0,0,248,246,1,
0,0,0,248,249,1,0,0,0,249,251,1,0,0,0,250,241,1,0,0,0,250,251,1,0,0,0,251,
253,1,0,0,0,252,205,1,0,0,0,252,233,1,0,0,0,253,58,1,0,0,0,254,260,5,34,
0,0,255,259,8,22,0,0,256,257,5,92,0,0,257,259,9,0,0,0,258,255,1,0,0,0,258,
256,1,0,0,0,259,262,1,0,0,0,260,258,1,0,0,0,260,261,1,0,0,0,261,263,1,0,
0,0,262,260,1,0,0,0,263,275,5,34,0,0,264,270,5,39,0,0,265,269,8,23,0,0,
266,267,5,92,0,0,267,269,9,0,0,0,268,265,1,0,0,0,268,266,1,0,0,0,269,272,
1,0,0,0,270,268,1,0,0,0,270,271,1,0,0,0,271,273,1,0,0,0,272,270,1,0,0,0,
273,275,5,39,0,0,274,254,1,0,0,0,274,264,1,0,0,0,275,60,1,0,0,0,276,280,
7,24,0,0,277,279,7,25,0,0,278,277,1,0,0,0,279,282,1,0,0,0,280,278,1,0,0,
0,280,281,1,0,0,0,281,62,1,0,0,0,282,280,1,0,0,0,283,284,5,91,0,0,284,285,
5,93,0,0,285,64,1,0,0,0,286,287,5,91,0,0,287,288,5,42,0,0,288,289,5,93,
0,0,289,66,1,0,0,0,290,303,3,61,30,0,291,292,5,46,0,0,292,302,3,61,30,0,
293,302,3,63,31,0,294,302,3,65,32,0,295,297,5,46,0,0,296,298,3,71,35,0,
297,296,1,0,0,0,298,299,1,0,0,0,299,297,1,0,0,0,299,300,1,0,0,0,300,302,
1,0,0,0,301,291,1,0,0,0,301,293,1,0,0,0,301,294,1,0,0,0,301,295,1,0,0,0,
302,305,1,0,0,0,303,301,1,0,0,0,303,304,1,0,0,0,304,68,1,0,0,0,305,303,
1,0,0,0,306,308,7,26,0,0,307,306,1,0,0,0,308,309,1,0,0,0,309,307,1,0,0,
0,309,310,1,0,0,0,310,311,1,0,0,0,311,312,6,34,0,0,312,70,1,0,0,0,313,314,
7,27,0,0,314,72,1,0,0,0,315,317,8,28,0,0,316,315,1,0,0,0,317,318,1,0,0,
0,318,316,1,0,0,0,318,319,1,0,0,0,319,74,1,0,0,0,29,0,88,131,148,200,205,
210,216,219,223,228,230,233,239,243,248,250,252,258,260,268,270,274,280,
299,301,303,309,318,1,6,0,0];
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,1,0,
1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,91,8,5,1,6,1,6,1,6,
1,7,1,7,1,7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,
1,12,1,12,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,
14,1,14,1,15,1,15,1,15,1,15,1,15,1,15,3,15,134,8,15,1,16,1,16,1,16,1,16,
1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,151,8,17,1,
18,1,18,1,18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,
24,1,24,1,24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,210,
8,27,1,28,1,28,1,29,3,29,215,8,29,1,29,4,29,218,8,29,11,29,12,29,219,1,
29,1,29,5,29,224,8,29,10,29,12,29,227,9,29,3,29,229,8,29,1,29,1,29,3,29,
233,8,29,1,29,4,29,236,8,29,11,29,12,29,237,3,29,240,8,29,1,29,3,29,243,
8,29,1,29,1,29,4,29,247,8,29,11,29,12,29,248,1,29,1,29,3,29,253,8,29,1,
29,4,29,256,8,29,11,29,12,29,257,3,29,260,8,29,3,29,262,8,29,1,30,1,30,
1,30,1,30,5,30,268,8,30,10,30,12,30,271,9,30,1,30,1,30,1,30,1,30,1,30,5,
30,278,8,30,10,30,12,30,281,9,30,1,30,3,30,284,8,30,1,31,1,31,5,31,288,
8,31,10,31,12,31,291,9,31,1,32,1,32,1,32,1,33,1,33,1,33,1,33,1,34,1,34,
1,34,1,34,1,34,1,34,1,34,4,34,307,8,34,11,34,12,34,308,5,34,311,8,34,10,
34,12,34,314,9,34,1,35,4,35,317,8,35,11,35,12,35,318,1,35,1,35,1,36,1,36,
1,37,4,37,326,8,37,11,37,12,37,327,0,0,38,1,1,3,2,5,3,7,4,9,5,11,6,13,7,
15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,16,33,17,35,18,37,19,39,
20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,28,57,0,59,29,61,30,63,
0,65,0,67,0,69,31,71,32,73,0,75,33,1,0,29,2,0,76,76,108,108,2,0,73,73,105,
105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,98,2,0,84,84,116,116,
2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,120,2,0,83,83,115,115,
2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,112,2,0,67,67,99,99,2,
0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,2,0,72,72,104,104,2,0,
89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,2,0,43,43,45,45,2,0,34,
34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,123,7,0,35,36,45,45,47,
58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,32,1,0,48,57,8,0,9,10,13,
13,32,34,39,41,44,44,60,62,91,91,93,93,353,0,1,1,0,0,0,0,3,1,0,0,0,0,5,
1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,
0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,
0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,
0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,
0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,
0,69,1,0,0,0,0,71,1,0,0,0,0,75,1,0,0,0,1,77,1,0,0,0,3,79,1,0,0,0,5,81,1,
0,0,0,7,83,1,0,0,0,9,85,1,0,0,0,11,90,1,0,0,0,13,92,1,0,0,0,15,95,1,0,0,
0,17,98,1,0,0,0,19,100,1,0,0,0,21,103,1,0,0,0,23,105,1,0,0,0,25,108,1,0,
0,0,27,113,1,0,0,0,29,119,1,0,0,0,31,127,1,0,0,0,33,135,1,0,0,0,35,142,
1,0,0,0,37,152,1,0,0,0,39,155,1,0,0,0,41,159,1,0,0,0,43,163,1,0,0,0,45,
166,1,0,0,0,47,175,1,0,0,0,49,179,1,0,0,0,51,186,1,0,0,0,53,193,1,0,0,0,
55,209,1,0,0,0,57,211,1,0,0,0,59,261,1,0,0,0,61,283,1,0,0,0,63,285,1,0,
0,0,65,292,1,0,0,0,67,295,1,0,0,0,69,299,1,0,0,0,71,316,1,0,0,0,73,322,
1,0,0,0,75,325,1,0,0,0,77,78,5,40,0,0,78,2,1,0,0,0,79,80,5,41,0,0,80,4,
1,0,0,0,81,82,5,91,0,0,82,6,1,0,0,0,83,84,5,93,0,0,84,8,1,0,0,0,85,86,5,
44,0,0,86,10,1,0,0,0,87,91,5,61,0,0,88,89,5,61,0,0,89,91,5,61,0,0,90,87,
1,0,0,0,90,88,1,0,0,0,91,12,1,0,0,0,92,93,5,33,0,0,93,94,5,61,0,0,94,14,
1,0,0,0,95,96,5,60,0,0,96,97,5,62,0,0,97,16,1,0,0,0,98,99,5,60,0,0,99,18,
1,0,0,0,100,101,5,60,0,0,101,102,5,61,0,0,102,20,1,0,0,0,103,104,5,62,0,
0,104,22,1,0,0,0,105,106,5,62,0,0,106,107,5,61,0,0,107,24,1,0,0,0,108,109,
7,0,0,0,109,110,7,1,0,0,110,111,7,2,0,0,111,112,7,3,0,0,112,26,1,0,0,0,
113,114,7,1,0,0,114,115,7,0,0,0,115,116,7,1,0,0,116,117,7,2,0,0,117,118,
7,3,0,0,118,28,1,0,0,0,119,120,7,4,0,0,120,121,7,3,0,0,121,122,7,5,0,0,
122,123,7,6,0,0,123,124,7,3,0,0,124,125,7,3,0,0,125,126,7,7,0,0,126,30,
1,0,0,0,127,128,7,3,0,0,128,129,7,8,0,0,129,130,7,1,0,0,130,131,7,9,0,0,
131,133,7,5,0,0,132,134,7,9,0,0,133,132,1,0,0,0,133,134,1,0,0,0,134,32,
1,0,0,0,135,136,7,10,0,0,136,137,7,3,0,0,137,138,7,11,0,0,138,139,7,3,0,
0,139,140,7,8,0,0,140,141,7,12,0,0,141,34,1,0,0,0,142,143,7,13,0,0,143,
144,7,14,0,0,144,145,7,7,0,0,145,146,7,5,0,0,146,147,7,15,0,0,147,148,7,
1,0,0,148,150,7,7,0,0,149,151,7,9,0,0,150,149,1,0,0,0,150,151,1,0,0,0,151,
36,1,0,0,0,152,153,7,1,0,0,153,154,7,7,0,0,154,38,1,0,0,0,155,156,7,7,0,
0,156,157,7,14,0,0,157,158,7,5,0,0,158,40,1,0,0,0,159,160,7,15,0,0,160,
161,7,7,0,0,161,162,7,16,0,0,162,42,1,0,0,0,163,164,7,14,0,0,164,165,7,
10,0,0,165,44,1,0,0,0,166,167,7,17,0,0,167,168,7,15,0,0,168,169,7,9,0,0,
169,170,7,5,0,0,170,171,7,14,0,0,171,172,7,2,0,0,172,173,7,3,0,0,173,174,
7,7,0,0,174,46,1,0,0,0,175,176,7,17,0,0,176,177,7,15,0,0,177,178,7,9,0,
0,178,48,1,0,0,0,179,180,7,17,0,0,180,181,7,15,0,0,181,182,7,9,0,0,182,
183,7,15,0,0,183,184,7,7,0,0,184,185,7,18,0,0,185,50,1,0,0,0,186,187,7,
17,0,0,187,188,7,15,0,0,188,189,7,9,0,0,189,190,7,15,0,0,190,191,7,0,0,
0,191,192,7,0,0,0,192,52,1,0,0,0,193,194,7,9,0,0,194,195,7,3,0,0,195,196,
7,15,0,0,196,197,7,10,0,0,197,198,7,13,0,0,198,199,7,17,0,0,199,54,1,0,
0,0,200,201,7,5,0,0,201,202,7,10,0,0,202,203,7,19,0,0,203,210,7,3,0,0,204,
205,7,20,0,0,205,206,7,15,0,0,206,207,7,0,0,0,207,208,7,9,0,0,208,210,7,
3,0,0,209,200,1,0,0,0,209,204,1,0,0,0,210,56,1,0,0,0,211,212,7,21,0,0,212,
58,1,0,0,0,213,215,3,57,28,0,214,213,1,0,0,0,214,215,1,0,0,0,215,217,1,
0,0,0,216,218,3,73,36,0,217,216,1,0,0,0,218,219,1,0,0,0,219,217,1,0,0,0,
219,220,1,0,0,0,220,228,1,0,0,0,221,225,5,46,0,0,222,224,3,73,36,0,223,
222,1,0,0,0,224,227,1,0,0,0,225,223,1,0,0,0,225,226,1,0,0,0,226,229,1,0,
0,0,227,225,1,0,0,0,228,221,1,0,0,0,228,229,1,0,0,0,229,239,1,0,0,0,230,
232,7,3,0,0,231,233,3,57,28,0,232,231,1,0,0,0,232,233,1,0,0,0,233,235,1,
0,0,0,234,236,3,73,36,0,235,234,1,0,0,0,236,237,1,0,0,0,237,235,1,0,0,0,
237,238,1,0,0,0,238,240,1,0,0,0,239,230,1,0,0,0,239,240,1,0,0,0,240,262,
1,0,0,0,241,243,3,57,28,0,242,241,1,0,0,0,242,243,1,0,0,0,243,244,1,0,0,
0,244,246,5,46,0,0,245,247,3,73,36,0,246,245,1,0,0,0,247,248,1,0,0,0,248,
246,1,0,0,0,248,249,1,0,0,0,249,259,1,0,0,0,250,252,7,3,0,0,251,253,3,57,
28,0,252,251,1,0,0,0,252,253,1,0,0,0,253,255,1,0,0,0,254,256,3,73,36,0,
255,254,1,0,0,0,256,257,1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,260,
1,0,0,0,259,250,1,0,0,0,259,260,1,0,0,0,260,262,1,0,0,0,261,214,1,0,0,0,
261,242,1,0,0,0,262,60,1,0,0,0,263,269,5,34,0,0,264,268,8,22,0,0,265,266,
5,92,0,0,266,268,9,0,0,0,267,264,1,0,0,0,267,265,1,0,0,0,268,271,1,0,0,
0,269,267,1,0,0,0,269,270,1,0,0,0,270,272,1,0,0,0,271,269,1,0,0,0,272,284,
5,34,0,0,273,279,5,39,0,0,274,278,8,23,0,0,275,276,5,92,0,0,276,278,9,0,
0,0,277,274,1,0,0,0,277,275,1,0,0,0,278,281,1,0,0,0,279,277,1,0,0,0,279,
280,1,0,0,0,280,282,1,0,0,0,281,279,1,0,0,0,282,284,5,39,0,0,283,263,1,
0,0,0,283,273,1,0,0,0,284,62,1,0,0,0,285,289,7,24,0,0,286,288,7,25,0,0,
287,286,1,0,0,0,288,291,1,0,0,0,289,287,1,0,0,0,289,290,1,0,0,0,290,64,
1,0,0,0,291,289,1,0,0,0,292,293,5,91,0,0,293,294,5,93,0,0,294,66,1,0,0,
0,295,296,5,91,0,0,296,297,5,42,0,0,297,298,5,93,0,0,298,68,1,0,0,0,299,
312,3,63,31,0,300,301,5,46,0,0,301,311,3,63,31,0,302,311,3,65,32,0,303,
311,3,67,33,0,304,306,5,46,0,0,305,307,3,73,36,0,306,305,1,0,0,0,307,308,
1,0,0,0,308,306,1,0,0,0,308,309,1,0,0,0,309,311,1,0,0,0,310,300,1,0,0,0,
310,302,1,0,0,0,310,303,1,0,0,0,310,304,1,0,0,0,311,314,1,0,0,0,312,310,
1,0,0,0,312,313,1,0,0,0,313,70,1,0,0,0,314,312,1,0,0,0,315,317,7,26,0,0,
316,315,1,0,0,0,317,318,1,0,0,0,318,316,1,0,0,0,318,319,1,0,0,0,319,320,
1,0,0,0,320,321,6,35,0,0,321,72,1,0,0,0,322,323,7,27,0,0,323,74,1,0,0,0,
324,326,8,28,0,0,325,324,1,0,0,0,326,327,1,0,0,0,327,325,1,0,0,0,327,328,
1,0,0,0,328,76,1,0,0,0,29,0,90,133,150,209,214,219,225,228,232,237,239,
242,248,252,257,259,261,267,269,277,279,283,289,308,310,312,318,327,1,6,
0,0];
private static __ATN: ATN;
public static get _ATN(): ATN {

View File

@@ -1,25 +1,26 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// Generated from FilterQuery.g4 by ANTLR 4.13.2
import {ParseTreeListener} from "antlr4";
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { SearchCallContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
/**
@@ -147,6 +148,16 @@ export default class FilterQueryListener extends ParseTreeListener {
* @param ctx the parse tree
*/
exitFunctionCall?: (ctx: FunctionCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
*/
enterSearchCall?: (ctx: SearchCallContext) => void;
/**
* Exit a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
*/
exitSearchCall?: (ctx: SearchCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,26 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// Generated from FilterQuery.g4 by ANTLR 4.13.2
import {ParseTreeVisitor} from 'antlr4';
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { SearchCallContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
/**
@@ -102,6 +103,12 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
* @return the visitor result
*/
visitFunctionCall?: (ctx: FunctionCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
* @return the visitor result
*/
visitSearchCall?: (ctx: SearchCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

View File

@@ -183,7 +183,7 @@
font-family: 'Space Mono', monospace !important;
border: none;
height: 36px;
min-height: 36px;
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -191,7 +191,7 @@
}
.ant-select-selector {
height: 36px;
min-height: 36px;
border-color: var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-radius: 0;

View File

@@ -30,6 +30,10 @@ body {
box-sizing: border-box;
}
.translate-safe {
display: contents;
}
// Respect user's reduced motion preference
@media (prefers-reduced-motion: reduce) {
* {

View File

@@ -380,6 +380,19 @@ describe('extractQueryPairs', () => {
consoleSpy.mockRestore();
});
it('does not turn a search() term into a pair', () => {
// The bare form lexes as a KEY; left in it becomes a phantom filter item.
expect(extractQueryPairs("search('err')")).toStrictEqual([]);
expect(extractQueryPairs('search(err)')).toStrictEqual([]);
expect(extractQueryPairs('search(')).toStrictEqual([]);
expect(
extractQueryPairs("search(err) AND service.name = 'api'").map(
(pair) => pair.key,
),
).toStrictEqual(['service.name']);
});
it('should treat lowercase exists as non-value operator', () => {
const input = 'body exists service.name contains "test"';
const result = extractQueryPairs(input);
@@ -821,3 +834,17 @@ describe('getQueryContextAtCursor - partial operator', () => {
expect(ctx.operatorToken).toBe('k');
});
});
describe('getQueryContextAtCursor - function context', () => {
// Each function keyword gets its own lexer token, and every one has to be
// registered as a function token for the editor to offer the function list.
it.each(['has', 'hasAny', 'hasAll', 'hasToken', 'search'])(
'resolves %s to function context',
(functionName) => {
const ctx = getQueryContextAtCursor(functionName, functionName.length);
expect(ctx.isInFunction).toBe(true);
expect(ctx.isInKey).toBe(false);
},
);
});

View File

@@ -1279,6 +1279,41 @@ export function getQueryContextAtCursor(
}
}
// The grammar skips whitespace outright, so hidden-channel tokens do not reach the
// stream today -- but the rest of this file guards against them, so keep the
// assumption in one place rather than spread across callers.
function nextVisibleIndex(tokens: IToken[], start: number): number {
let index = start;
while (index < tokens.length && tokens[index].channel !== 0) {
index += 1;
}
return index;
}
// Returns the token index just past the parenthesised argument list at
// argumentStart, or argumentStart when none follows (a call the user is still
// typing). An unclosed list consumes the remainder.
function indexPastArguments(tokens: IToken[], argumentStart: number): number {
let index = nextVisibleIndex(tokens, argumentStart);
if (index >= tokens.length || tokens[index].type !== FilterQueryLexer.LPAREN) {
return argumentStart;
}
let depth = 0;
for (; index < tokens.length; index++) {
if (tokens[index].type === FilterQueryLexer.LPAREN) {
depth += 1;
} else if (tokens[index].type === FilterQueryLexer.RPAREN) {
depth -= 1;
if (depth === 0) {
return index + 1;
}
}
}
return index;
}
/**
* Extracts all key-operator-value triplets from a query string
* This is useful for getting value suggestions based on the current key and operator
@@ -1324,6 +1359,14 @@ export function extractQueryPairs(query: string): IQueryPair[] {
continue;
}
// A search() term is free text, not a key: the bare form search(x) lexes
// as one, and left in it surfaces as a phantom filter item -- the log
// detail drawer rebuilds its filters from these pairs.
if (token.type === FilterQueryLexer.SEARCH) {
iterator = indexPastArguments(allTokens, iterator);
continue;
}
// If token is a KEY, start a new pair
if (
token.type === FilterQueryLexer.KEY &&

View File

@@ -77,6 +77,7 @@ export function isFunctionToken(tokenType: number): boolean {
FilterQueryLexer.HASANY,
FilterQueryLexer.HASALL,
FilterQueryLexer.HASTOKEN,
FilterQueryLexer.SEARCH,
].includes(tokenType);
}

2
go.mod
View File

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

4
go.sum
View File

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

View File

@@ -3803,6 +3803,10 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
return
}
queryRangeParams.UseJSONBody = aH.Signoz.Flagger.BooleanOrEmpty(
r.Context(), flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID),
)
// add temporality for each metric
temporalityErr := aH.PopulateTemporality(r.Context(), orgID, queryRangeParams)
if temporalityErr != nil {

View File

@@ -361,7 +361,7 @@ func generateAggregateClause(panelType v3.PanelType, start, end int64, aggOp v3.
}
}
func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.BuilderQuery, graphLimitQtype string) (string, error) {
func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.BuilderQuery, graphLimitQtype string, useJSONBody bool) (string, error) {
// timerange will be sent in epoch millisecond
logsStart := utils.GetEpochNanoSecs(start)
logsEnd := utils.GetEpochNanoSecs(end)
@@ -405,6 +405,9 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
if mq.AggregateOperator == v3.AggregateOperatorNoOp {
// with noop any filter or different order by other than ts will use new table
sqlSelect := constants.LogsSQLSelectV2
if useJSONBody {
sqlSelect = constants.LogsSQLSelectV2WithBodyJSON
}
queryTmpl := sqlSelect + "from signoz_logs.%s where %s%s order by %s"
query := fmt.Sprintf(queryTmpl, DISTRIBUTED_LOGS_V2, timeFilter, filterSubQuery, orderBy)
return query, nil
@@ -517,7 +520,7 @@ func PrepareLogsQuery(start, end int64, queryType v3.QueryType, panelType v3.Pan
return query, nil
} else if options.GraphLimitQtype == constants.FirstQueryGraphLimit {
// give me just the group_by names (no values)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
if err != nil {
return "", err
}
@@ -525,14 +528,14 @@ func PrepareLogsQuery(start, end int64, queryType v3.QueryType, panelType v3.Pan
return query, nil
} else if options.GraphLimitQtype == constants.SecondQueryGraphLimit {
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
if err != nil {
return "", err
}
return query, nil
}
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
if err != nil {
return "", err
}

View File

@@ -899,7 +899,7 @@ func Test_buildLogsQuery(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildLogsQuery(tt.args.panelType, tt.args.start, tt.args.end, tt.args.step, tt.args.mq, tt.args.graphLimitQtype)
got, err := buildLogsQuery(tt.args.panelType, tt.args.start, tt.args.end, tt.args.step, tt.args.mq, tt.args.graphLimitQtype, false)
if (err != nil) != tt.wantErr {
t.Errorf("buildLogsQuery() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -215,18 +215,18 @@ func (qb *QueryBuilder) PrepareQueries(params *v3.QueryRangeParamsV3) (map[strin
case v3.DataSourceLogs:
// for ts query with limit replace it as it is already formed
if compositeQuery.PanelType == v3.PanelTypeGraph && query.Limit > 0 && len(query.GroupBy) > 0 {
limitQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.FirstQueryGraphLimit})
limitQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.FirstQueryGraphLimit, UseJSONBody: params.UseJSONBody})
if err != nil {
return nil, err
}
placeholderQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.SecondQueryGraphLimit})
placeholderQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.SecondQueryGraphLimit, UseJSONBody: params.UseJSONBody})
if err != nil {
return nil, err
}
query := fmt.Sprintf(placeholderQuery, limitQuery)
queries[queryName] = query
} else {
queryString, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: ""})
queryString, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: "", UseJSONBody: params.UseJSONBody})
if err != nil {
return nil, err
}

View File

@@ -196,13 +196,18 @@ const (
"CAST((attributes_bool_key, attributes_bool_value), 'Map(String, Bool)') as attributes_bool," +
"CAST((resources_string_key, resources_string_value), 'Map(String, String)') as resources_string," +
"CAST((scope_string_key, scope_string_value), 'Map(String, String)') as scope "
LogsSQLSelectV2 = "SELECT " +
"timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, " +
"attributes_string, " +
logsSQLSelectV2Head = "SELECT " +
"timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, "
logsSQLSelectV2Tail = "attributes_string, " +
"attributes_number, " +
"attributes_bool, " +
"resources_string, " +
"scope_string "
LogsSQLSelectV2 = logsSQLSelectV2Head + "body, " + logsSQLSelectV2Tail
// Orgs on JSON bodies keep the body in body_v2 and have the body column written empty.
// Stringified because filters emit a bare `body`, which ClickHouse resolves to this alias:
// as JSON it fails every string comparison, as String it matches against the body text.
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "toString(body_v2) as body, " + logsSQLSelectV2Tail
TracesExplorerViewSQLSelectWithSubQuery = "(SELECT traceID, durationNano, " +
"serviceName, name FROM %s.%s WHERE parentSpanID = '' AND %s ORDER BY durationNano DESC LIMIT 1 BY traceID"
TracesExplorerViewSQLSelectBeforeSubQuery = "SELECT subQuery.serviceName as `subQuery.serviceName`, subQuery.name as `subQuery.name`, count() AS " +

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