Compare commits

..

16 Commits

Author SHA1 Message Date
Gaurav Tewari
0ae98b05c5 feat: loading in skeleton 2026-10-07 17:51:36 +05:30
Gaurav Tewari
7c76ba8d9e feat: update header options 2026-10-07 17:35:39 +05:30
Gaurav Tewari
104758f3cf refactor(trace-details): derive header filter range from the trace summary
Assisted-by: Claude Opus 5.5
2026-10-07 17:15:20 +05:30
Gaurav Tewari
504be0dfd7 feat(trace-details): read header metadata from the trace summary API
Assisted-by: Claude Opus 5.5
2026-10-07 16:59:38 +05:30
Nityananda Gohain
636c023483 feat: add trace summary endpoint (#12911)
<!--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

- Adds `GET /api/v1/traces/{traceID}/summary`. It returns the trace
header fields shown with the waterfall, plus AI token and cost totals,
using one query.
- The waterfall loads every span to get these fields. It will stop
returning them once the frontend uses this endpoint.
- Fixes these waterfall bugs:
  - On traces over 10k spans, the entry point is empty.
- If some spans point to a parent that was never received, the root can
change on every reload.



| Trace size | Endpoint | Latency | Server memory | Data sent to server
|
|---|---|---|---|---|
| 2M spans | summary | 0.65 s | 84 MiB | 3 KB |
| 2M spans | waterfall | 12.5 s | 2.6 GiB | 128 MiB |

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/265

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- AI token and cost fields are now typed `number`, the same as in
metadata.

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-07 11:05:13 +00:00
Aditya Singh
1d54a93e02 feat(quick-filters): saved views slots in the sidebar layout (#12994)
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
- first slice of saved views in the quick filters sidebar..
`QuickFiltersLayout` gets a `savedViewProps` next to `quickFilterProps`,
both optional. views header sits above the filters, `≡` opens an All
views panel in the sidebar's place and the filters slide right over the
content as one drawer. nothing remounts, so expanded sections and an
open settings drawer survive the toggle.
- header and panel are placeholders for now (dummy name, empty list)..
this PR is the layout, the real header / list / actions come on top of
it. behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- metrics has no quick filters, so it gets the layout with only the
views side.. header on top, list always visible, nothing slides.
- logs explorer moved onto `QuickFiltersLayout`.. it was the last one
mounting quick filters on its own. content wrapper now hands a definite
height down so logs can keep sizing its list from the pane instead of
`calc(100vh - ..)`.
- quick filters settings drawer animates with transform instead of
width.. width relaid out the drawer every frame, that was the jitter.
clipped so it comes out from behind the filters.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71


#### Screenshots/ Screen recording 

<img width="1728" height="1001" alt="12994 - metrics dummy section"
src="https://github.com/user-attachments/assets/7a57fd43-a11b-44e7-a8ca-4b197826bd36"
/>


https://github.com/user-attachments/assets/fd6a4ac4-6464-4059-9986-99d42e159a3d




#### Additional Information
- flag off checked on logs / traces / metrics / exceptions.. sidebar,
list scroll and pane scroll same as before.
- borders and l1 background per figma, quick filters width still 280px..
will take that up with design.
- cc. @H4ad since `QuickFiltersLayout` is from the scroll fix PR.
2026-10-07 07:08:31 +00:00
Abhi kumar
ea127308b6 chore(storybook): add new-panel picker and config pane stories (#13058)
#### Description

- Stories for the current new-panel picker and panel editor config pane,
as the visual baseline for #12992 and #13010.
- Dashboard detail: picker opened from the toolbar, with a kind
selected, without sections, and from a section's menu.
- Panel editor: one story per panel kind with every config section
expanded; Area and Text added to the editor's kind list.
- `story-shots.mjs`: round the grown viewport height up, since
Playwright rejects fractional sizes.
2026-10-07 06:22:19 +00:00
Naman Verma
9f4108350a feat: add heatmap support in dashboard schema (#12844)
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
<!--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

To be merged along with UI changes

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

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

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-10-06 18:35:51 +00:00
Nikhil Mantri
32350e61e5 feat(ruler): add saved views CRUD APIs for the alerts list page (#12814)
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

- Adds saved views for the alerts list page: a view stores the v3 list
params (`query` DSL string, `states`, `sort`, `order`) and the FE
replays them, mirroring dashboard saved views.
- New CRUD APIs: `GET/POST /api/v2/rule_views`, `PUT/DELETE
/api/v2/rule_views/{id}`. Open to all roles including viewer, like
dashboard views; shared org-wide.
- New `rule_view` table (migration 127), same shape as `dashboard_view`
plus an explicit index on `org_id` (neither Postgres nor SQLite
auto-indexes an FK's referencing column).
- Validation on save: name (required, trimmed, max 64), `version` must
be `v1`, query length-capped at 1024 (not compiled, a work-in-progress
query is saveable), `states`/`sort`/`order` enum-checked; zero
sort/order normalize to the list defaults (`updated_at`/`desc`); unknown
body fields rejected.
- Covered by unit tests and a new integration suite
(`alerts/07_rule_views.py`: rejection table, 404s, full lifecycle);
OpenAPI spec regenerated.
- discussion: SigNoz/pulse-pod#304.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#227

#### Additional Information

- Based on #12780 (alerts listing page revamp), which this PR's branch
is cut from; backend only, FE ships separately.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-10-06 09:35:15 +00:00
Gaurav Tewari
2bd0153a51 chore: replace banner in llm with callout (#13037)
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
<!--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

Prev. we had a banner with this hardcoded values. now we have replaced
it. the callout component.


#### Issues closed by this PR

<!--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="1517" height="325" alt="image"
src="https://github.com/user-attachments/assets/257639f0-0e97-4606-b9c9-55ee8cabe53f"
/>

Now - 
<img width="1515" height="339" alt="image"
src="https://github.com/user-attachments/assets/b02a9cae-3fc0-4980-90c9-2bde1c9b2c98"
/>

#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-06 05:57:19 +00:00
dependabot[bot]
23776bbd26 chore(deps): bump urllib3 from 2.7.0 to 2.8.0 in /tests (#13032)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.8.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project. If your company or
organization uses Python and would benefit from HTTP/2 support in
Requests, pip, cloud SDKs, and thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Security</h2>
<p>Fixed the following security issues:</p>
<ul>
<li>The TLS configuration for HTTPS proxies could be ignored or
overridden. (High severity, GHSA-8988-9cw3-xx77)</li>
<li><code>HTTPResponse.stream()</code> and <code>read_chunked()</code>
could buffer a chunk-size line of unbounded length in memory. (High
severity, GHSA-vxq7-64xx-v4gw)</li>
<li>Chunked Deflate streaming could enter an infinite loop. (Medium
severity, GHSA-gh4c-6fx4-qh6g)</li>
</ul>
<blockquote>
<p>[!IMPORTANT]
urllib3 2.8.0 fixes HTTPS proxy TLS configuration being ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.</p>
<p>Configure proxy CA certificates and client certificates in
<code>proxy_ssl_context</code>, and proxy identity checks with
<code>proxy_assert_hostname</code> or
<code>proxy_assert_fingerprint</code>. Destination client certificates
and identity overrides no longer apply to HTTPS forwarding proxy
connections.</p>
</blockquote>
<blockquote>
<p>[!NOTE]
CVE IDs had not yet been assigned to these advisories at the time of
release due to a backlog at GitHub's CNA.</p>
</blockquote>
<h2>Deprecations &amp; Removals</h2>
<ul>
<li>Deprecated using an empty collection as the <code>Retry</code>
option <code>allowed_methods</code> to retry any verb. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5044">#5044</a>)</li>
</ul>
<h2>Features</h2>
<ul>
<li>Added <code>Url.auth_decoded</code> and
<code>Url.auth_decoded_joined</code> convenience properties to the
result of <code>parse_url()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/4945">#4945</a>)</li>
<li>Added <code>basic_auth_encoding</code> and
<code>proxy_basic_auth_encoding</code> parameters to
<code>urllib3.util.make_headers()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5092">#5092</a>)</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>
<p>Fixed response header handling to replace obsolete folded header
lines (<code>obs-fold</code>) with spaces in accordance with RFC 9112,
preventing raw CRLF sequences from appearing in header values such as
<code>Set-Cookie</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/1362">#1362</a>)</p>
</li>
<li>
<p>Fixed usage of <code>proxy_ssl_context</code> with
<code>ProxyManager</code> when
<code>use_forwarding_for_https=True</code>. Passing
<code>ssl_context</code> instead of <code>proxy_ssl_context</code> for
HTTPS proxies in this configuration now emits a
<code>FutureWarning</code> and will raise an error in v3.0. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/2577">#2577</a>)</p>
</li>
<li>
<p>Changed behavior of the default <code>ConnectionPool.pool</code>
initialization. <code>LifoQueue</code> is now resolved from the
<code>queue</code> module after the <code>ConnectionPool</code> is
instantiated instead of using the default cached <code>QueueCls</code>
class property. This is done because sometimes the
<code>queue.LifoQueue</code> is monkey-patched late in the program, such
as by gevent. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3289">#3289</a>)</p>
</li>
<li>
<p>Raised <code>UnrewindableBodyError</code> instead of
<code>ValueError</code> when retrying a request whose body had
<code>tell()</code> but not <code>seek()</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3779">#3779</a>)</p>
</li>
<li>
<p>Decoded percent-encoded SOCKS proxy credentials before authenticating
with the proxy server. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3785">#3785</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPResponse.drain_conn()</code> to discard unread
response data in 64 KiB chunks (same as the default <code>amt</code>
when doing <code>HTTPResponse.stream(...)</code>). (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5019">#5019</a>)</p>
</li>
<li>
<p>Fixed <code>is_ipaddress()</code> to detect non-standard IPv4 forms
accepted by <code>socket.connect</code>, such as hex
(<code>0x7f000001</code>), octal (<code>0177.0.0.1</code>), and decimal
integers (<code>2130706433</code>), ensuring SSL certificate
verification uses the correct mode for these addresses. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5029">#5029</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPConnectionPool.urlopen</code> raising a misleading
<code>FullPoolError</code> instead of <code>ValueError</code> when
called with an invalid <code>timeout</code> argument on a pool created
with <code>block=True</code>. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5059">#5059</a>)</p>
</li>
<li>
<p>Fixed port-zero handling to preserve explicit <code>:0</code> values
instead of substituting the default ports 80 or 443 in URL parsing, pool
selection, proxy configuration, <code>connection_from_url()</code>, and
HTTP/2 request authority. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5071">#5071</a>,
<a
href="https://redirect.github.com/urllib3/urllib3/issues/5101">#5101</a>)</p>
</li>
<li>
<p>Fixed a bug where <code>PoolManager</code> passed the
<code>assert_hostname</code> and <code>assert_fingerprint</code>
parameters to HTTP connection pools. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5077">#5077</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPConnectionPool.urlopen()</code> and HTTP proxy
forwarding to strip URL fragments from absolute request targets before
sending requests. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5079">#5079</a>)</p>
</li>
<li>
<p>Added safeguards to the proxy tunneling code to prevent potential
security issues when handling invalid characters in the proxy host and
HTTP headers. This change affects users of Python 3.10, Python 3.11, and
Python 3.12 when the standard library does not contain the fix; those on
newer Python versions should upgrade to 3.13.14+ or 3.14.5+ to get the
same security fixes. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5091">#5091</a>)</p>
</li>
<li>
<p>Fixed <code>HTTPSConnection.connect()</code> overriding
<code>ProxyConfig.ssl_context</code>'s certificate policy and proxy
identity checks with the target connection's TLS settings when
forwarding through an HTTPS proxy.</p>
<p><code>HTTPSConnection</code> no longer applies target SNI,
assertions, or client credentials to forwarding proxy handshakes and
continues to use its <code>ssl_context</code> as a fallback when an
HTTPS proxy forwards an HTTP target. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5093">#5093</a>)</p>
</li>
<li>
<p>Fixed URL parsing to more strictly enforce RFC 3986 host syntax,
rejecting invalid host input such as raw spaces and control characters,
malformed percent-encodings, and percent-encoded control characters in
HTTP(S) hosts and IPv6 zone identifiers, including proxy CONNECT tunnel
targets. Host normalization now also follows RFC 3986 normalization
rules for percent-encoded octets by decoding percent-encoded unreserved
characters and uppercasing the hexadecimal digits of retained
percent-encoded octets. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5095">#5095</a>)</p>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's
changelog</a>.</em></p>
<blockquote>
<h1>2.8.0 (2026-09-15)</h1>
<h2>Security</h2>
<p>Fixed the following security issues:</p>
<ul>
<li>The TLS configuration for HTTPS proxies could be ignored or
overridden.
(High severity, <code>GHSA-8988-9cw3-xx77
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-8988-9cw3-xx77&gt;</code>__)</li>
<li><code>HTTPResponse.stream()</code> and <code>read_chunked()</code>
could buffer a chunk-size
line of unbounded length in memory. (High severity,
<code>GHSA-vxq7-64xx-v4gw
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-vxq7-64xx-v4gw&gt;</code>__)</li>
<li>Chunked Deflate streaming could enter an infinite loop. (Medium
severity,
<code>GHSA-gh4c-6fx4-qh6g
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-gh4c-6fx4-qh6g&gt;</code>__)</li>
</ul>
<p>.. caution::</p>
<pre><code>urllib3 2.8.0 fixes HTTPS proxy TLS configuration being
ignored or
overridden by destination settings. Configurations relying on that
behavior may require changes.
<p>Configure proxy CA certificates and client certificates in
<code>proxy_ssl_context</code>, and proxy identity checks with
<code>proxy_assert_hostname</code> or
<code>proxy_assert_fingerprint</code>.
Destination client certificates and identity overrides no longer
apply to HTTPS forwarding proxy connections.
</code></pre></p>
<h2>Deprecations &amp; Removals</h2>
<ul>
<li>Deprecated using an empty collection as the <code>Retry</code>
option
<code>allowed_methods</code> to retry any verb.
(<code>[#5044](https://github.com/urllib3/urllib3/issues/5044)
&lt;https://github.com/urllib3/urllib3/issues/5044&gt;</code>__)</li>
</ul>
<h2>Features</h2>
<ul>
<li>Added <code>Url.auth_decoded</code> and
<code>Url.auth_decoded_joined</code> convenience
properties to the result of <code>parse_url()</code>.
(<code>[#4945](https://github.com/urllib3/urllib3/issues/4945)
&lt;https://github.com/urllib3/urllib3/issues/4945&gt;</code>__)</li>
<li>Added <code>basic_auth_encoding</code> and
<code>proxy_basic_auth_encoding</code> parameters to
<code>urllib3.util.make_headers()</code>.
(<code>[#5092](https://github.com/urllib3/urllib3/issues/5092)
&lt;https://github.com/urllib3/urllib3/issues/5092&gt;</code>__)</li>
</ul>
<h2>Bugfixes</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b1d30ab61f"><code>b1d30ab</code></a>
Release 2.8.0</li>
<li><a
href="9016d7e8af"><code>9016d7e</code></a>
Skip <code>test_read_chunked_with_trailing_data_does_not_hang</code> for
brotlicffi (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5258">#5258</a>)</li>
<li><a
href="9101f581a8"><code>9101f58</code></a>
Fix <code>nox -s docs</code> warning (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5256">#5256</a>)</li>
<li><a
href="cd770b059b"><code>cd770b0</code></a>
Merge commit from fork</li>
<li><a
href="ea2ad7b21a"><code>ea2ad7b</code></a>
Merge commit from fork</li>
<li><a
href="0716e31534"><code>0716e31</code></a>
Fix loading unencrypted client keys with a password in pyOpenSSL (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5255">#5255</a>)</li>
<li><a
href="43c68c8b43"><code>43c68c8</code></a>
Test pickling of <code>InvalidChunkLength</code> (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5247">#5247</a>)</li>
<li><a
href="308b279b3f"><code>308b279</code></a>
Share security policy between GitHub and Read the Docs (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5253">#5253</a>)</li>
<li><a
href="53fa0731b2"><code>53fa073</code></a>
Add policy on duplicate pull requests (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5252">#5252</a>)</li>
<li><a
href="5f2a6a843d"><code>5f2a6a8</code></a>
Assert on the ALPN extension in test_tunnel_sets_http_11_alpn (<a
href="https://redirect.github.com/urllib3/urllib3/issues/5232">#5232</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 04:07:41 +00:00
Srikanth Chekuri
f941d1ea11 chore(deps): bump grpc to v1.83.2 and opentelemetry-go to v1.45.0 (#13047)
#### Description

- Bump `google.golang.org/grpc` to v1.83.2 and the opentelemetry-go core
and trace exporter modules to v1.45.0. This fixes the open Dependabot
alerts for these modules.
- Take the resource schema URL from `sdkresource.Default()`. From
v1.44.0, the SDK uses semconv 1.41.0. With the pinned 1.40.0 URL, the
resource merge fails and the server does not start.
- Keep the otel log modules at their current versions. Their v0.21.0 API
breaks `otelzap` v0.13.0 and the log exporters that
`signoz-otel-collector` v0.144.6 uses.
- Replaces #12828, #12900, #12902, #12903 and #12904.
2026-10-06 03:23:37 +00:00
dependabot[bot]
185677634c chore(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 in /scripts/promqltestcorpus (#12804)
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
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.83.1 to 1.83.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/winklemad"><code>@​winklemad</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="030ee8becb"><code>030ee8b</code></a>
Update version to 1.83.2 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9375">#9375</a>)</li>
<li><a
href="8668b69c16"><code>8668b69</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9365">#9365</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9366">#9366</a>)</li>
<li><a
href="a3e952d2b7"><code>a3e952d</code></a>
cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9346">#9346</a> to
v1.83.x and update x/net dependency (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9369">#9369</a>)</li>
<li><a
href="58f8fd9a00"><code>58f8fd9</code></a>
Change version to 1.83.2-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9337">#9337</a>)</li>
<li>See full diff in <a
href="https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 13:04:53 +00:00
Naman Verma
e8c22cb259 fix(promql): serve transpiled series without their synthetic __name__ (#13031)
<!--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

`max_over_time` expects a range vector as input, which is `labels ->
[(timestamp,value),...]`. it turns each entry into
`labelsWith__name__removed -> maxOfAllValues`.

However, if two entries have `labelsWith__name__removed` as the same,
then `max_over_time` throws an error. For eg if the input is:
1. {"host":"a", "__name__": "transpiled_1"} -> ....
2. {"host":"a", "__name__": "transpiled_2"} -> ....
then `max_over_time` will break.

Currently, `executeHybrid` in
`pkg/prometheus/clickhouseprometheusv2/transpiler_exec.go` always puts
in the `__name__` label as `signoz_transpiled_*`, which can lead to the
above scenario.

Removing this `__name__` label fixes that issue. Also, nothing ever
needs this label. When the engine asks for `signoz_transpiled_0`, our
storage finds the data with a map lookup on that name and returns it.
The series' own labels play no part in the lookup.

After this change, no synthetic `__name__` exists anymore. So, the code
that stripped it after the engine ran, and `mergeMatrixByLabelset`,
which re-merged the rows those names had split, are deleted. The
engine's own merging handles this now.

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

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

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

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

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-10-05 09:56:02 +00:00
Vikrant Gupta
0aeef0dc46 fix(auth): clear stale password errors on the reset password page (#13035)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- Submitting a weak password on the reset page returned the backend
`invalid_password` error, and the pane stayed up after the password was
fixed, replaying its shake while typing.
- The mismatch flag was imperative state set on blur, so it went stale
when a field was cleared; toggling it remounted the API error pane.
- Validation is now derived from watched form values, the same way
SignUp does it, and the mutation is reset on edit so the API error
clears as soon as either field changes.
- Added tests for both cases.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#144

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/48dcfa17-9fbc-47bc-83c5-75b0bb46ced0
2026-10-02 12:52:30 +00:00
Tushar Vats
7663ab02d4 feat(logparsingpipeline): add json_body_dual_ingestion flag for dual body ingestion (#12831)
#### Description

Server counterpart to SigNoz/signoz-otel-collector#891, which makes the
collector write each log body to both the legacy `body` column and
`body_v2` while a `json_body_dual_ingestion` flag is on.

- Adds the `json_body_dual_ingestion` feature flag (experimental, off by
default).
- Normalize is placed by read mode, so user pipelines always see the
body the explorer shows. With `use_json_body` on it stays ahead of user
pipelines. With dual ingestion alone, reads are still on the legacy
`body`, so it runs after them and only feeds `body_v2`.
- Whenever dual ingestion is on, the operator carries
`json_body_dual_ingestion: true` so it stashes the original body for the
exporter to restore. Running last under dual makes that stash the
post-pipeline body, exactly what legacy ingestion stores today.
- The pipeline preview follows the same rule and normalizes only under
`use_json_body`.

Design notes: [Normalize Operator and
Pipelines](https://app.notion.com/p/signoz/Normalize-Operator-and-Pipelines-3d7fcc6bcd19802396b9e8e817e30381),
[Dual JSON body
ingestion](https://app.notion.com/p/signoz/Dual-JSON-body-ingestion-3c6fcc6bcd198049963bce6ce2648af8)

#### Additional Information

- Collectors must run a build containing
SigNoz/signoz-otel-collector#891 before this flag is turned on. Verified
locally against v0.144.9: an older collector does not reject the unknown
operator key, it silently ignores it (operator configs are decoded with
`confmap.WithIgnoreUnused()`), runs normalize, and writes the normalized
body into the legacy `body` column until it is upgraded. No collector
release includes #891 yet.
- The exporter's own `json_body_dual_ingestion` key is collector deploy
config and is flipped together with this flag; the server does not set
it.
- Toggling either flag does not bump the pipeline config version, so
connected agents need a new pipeline save to pick up the operator or its
position. Same caveat as `use_json_body` today.
- Under dual, normalize is last among the SigNoz pipelines; custom
collector processors placed after them still see the normalized map.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-02 10:56:38 +00:00
335 changed files with 16027 additions and 6022 deletions

View File

@@ -66,7 +66,7 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: check-semconv-generated-files-and-product-literals
- name: check-semconv-generated-files
run: make semconv-check
build:
if: |

View File

@@ -68,6 +68,7 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -263,8 +263,8 @@ semconv-generate: ## Regenerate semantic-convention families for Go and TypeScri
@go run ./scripts/semconv
.PHONY: semconv-check
semconv-check: ## Verify generated semantic-convention files and reject old-name product literals
@go run ./scripts/semconv -check -lint
semconv-check: ## Fail if the generated semantic-convention files are stale
@go run ./scripts/semconv -check
.PHONY: gen-mocks
gen-mocks:

File diff suppressed because it is too large Load Diff

View File

@@ -131,7 +131,7 @@ A `LogicalField` is one meaning: one name, context, and data type, backed by one
The resolution order is the same for every storage, in the filter and in every select field, group by, order by, and aggregation:
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) matches its own context first, then a column the storage knows under that context. Only when both miss does it look up as if it had no context. `span.http.method` then corrects to the attribute. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are kept as written.
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation. A key written as `exact(key)` in an expression, or a structured key with `fieldResolution: exact`, reads its own spelling alone.
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A select field, group by, order by, or aggregation keeps every interpretation in metadata order and folds them, so it shows the value wherever it is.
4. **Intrinsic column first**, bare keys only. A column every row has leads the list. Metadata can report the column, or the storage's `Fallback` can. When only `Fallback` knows the column, its key carries the data type the column reads as (`querybuilder.ColumnDataType`). A same-named metadata key of a contradicting type then drops, and a time column merges none. When metadata reports the column too, every match reads. A metadata gap degrades to the correct column, never to a corrupt metadata key.
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.

View File

@@ -2,6 +2,8 @@
// Mock for uplot library used in tests
export interface MockUPlotInstance {
/** Consumers read `root.parentElement` to detect a re-mounted container. */
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
const root = document.createElement('div');
// Real uPlot mounts its root inside the target; without it a re-render reads
// `root.parentElement` off undefined and throws.
target?.appendChild(root);
return {
root,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
};
};
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
return createMockUPlotInstance(target);
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -417,7 +417,7 @@ for (const theme of themes.length ? themes : [null]) {
break;
}
height = needed;
height = Math.ceil(needed);
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}

View File

@@ -19,7 +19,9 @@ import type {
import type {
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
DeleteRuleViewPathParameters,
GetRuleByID200,
GetRuleByIDPathParameters,
GetRuleHistoryFilterKeys200,
@@ -40,6 +42,7 @@ import type {
GetRuleHistoryTopContributors200,
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
@@ -47,8 +50,11 @@ import type {
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
RuletypesPostableRuleViewDTO,
TestRule200,
UpdateRuleByIDPathParameters,
UpdateRuleView200,
UpdateRuleViewPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -74,6 +80,351 @@ const withQueryKey = <T extends object, K>(
return result;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List rule saved views
*/
export const listRuleViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRuleViews200>({
url: `/api/v2/rule_views`,
method: 'GET',
signal,
});
};
export const getListRuleViewsQueryKey = () => {
return [`/api/v2/rule_views`] as const;
};
export const getListRuleViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
signal,
}) => listRuleViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRuleViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listRuleViews>>
>;
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List rule saved views
*/
export function useListRuleViews<
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRuleViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List rule saved views
*/
export const invalidateListRuleViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRuleViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
* @summary Create rule saved view
*/
export const createRuleView = (
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRuleView201>({
url: `/api/v2/rule_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getCreateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
const mutationKey = ['createRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRuleView>>,
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createRuleView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createRuleView>>
>;
export type CreateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create rule saved view
*/
export const useCreateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
return useMutation(getCreateRuleViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete rule saved view
*/
export const deleteRuleView = (
{ id }: DeleteRuleViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rule_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
const mutationKey = ['deleteRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteRuleView>>,
{ pathParams: DeleteRuleViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteRuleView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRuleView>>
>;
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete rule saved view
*/
export const useDeleteRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
return useMutation(getDeleteRuleViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update rule saved view
*/
export const updateRuleView = (
{ id }: UpdateRuleViewPathParameters,
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateRuleView200>({
url: `/api/v2/rule_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getUpdateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
const mutationKey = ['updateRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRuleView>>,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRuleView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRuleView>>
>;
export type UpdateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update rule saved view
*/
export const useUpdateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated

View File

@@ -5291,6 +5291,87 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
export enum DashboardtypesHeatmapYScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesHeatmapAxisDTO {
scale?: DashboardtypesHeatmapYScaleDTO;
}
export interface DashboardtypesHeatmapAxesDTO {
y?: DashboardtypesHeatmapAxisDTO;
}
export enum DashboardtypesHeatmapColorModeDTO {
palette = 'palette',
opacity = 'opacity',
}
export enum DashboardtypesHeatmapPaletteDTO {
ice = 'ice',
moss = 'moss',
rust = 'rust',
graphite = 'graphite',
ember = 'ember',
lagoon = 'lagoon',
orchid = 'orchid',
verdant = 'verdant',
lava = 'lava',
beacon = 'beacon',
}
export enum DashboardtypesHeatmapColorScaleDTO {
log = 'log',
sqrt = 'sqrt',
linear = 'linear',
}
export interface DashboardtypesHeatmapColorsDTO {
/**
* @type string
*/
fill?: string;
/**
* @type number,null
*/
maxCount?: number | null;
/**
* @type number,null
*/
minCount?: number | null;
mode?: DashboardtypesHeatmapColorModeDTO;
palette?: DashboardtypesHeatmapPaletteDTO;
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
*/
steps?: number;
}
export interface DashboardtypesHeatmapChartAppearanceDTO {
colors?: DashboardtypesHeatmapColorsDTO;
}
export interface DashboardtypesHeatmapPanelSpecDTO {
axes?: DashboardtypesHeatmapAxesDTO;
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
/**
* @enum signoz/HeatmapPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5300,7 +5381,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6226,6 +6308,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable
@@ -10328,6 +10411,56 @@ export interface RuletypesListableRuleDTO {
updatedBy?: string;
}
export interface RuletypesRuleViewDataDTO {
order?: RuletypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: RuletypesListSortDTO;
/**
* @type array
*/
states?: string[];
/**
* @type string
*/
version: string;
}
export interface RuletypesRuleViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface RuletypesListableRuleViewsDTO {
/**
* @type array
*/
views: RuletypesRuleViewDTO[];
}
export interface RuletypesListableRulesDTO {
/**
* @type array
@@ -10496,6 +10629,14 @@ export interface RuletypesPostableRuleDTO {
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
name: string;
}
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
export type RuletypesRuleDTOLabels = { [key: string]: string };
@@ -11194,6 +11335,82 @@ export interface SpantypesGettableTraceAggregationsDTO {
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesTraceAITokensDTO {
/**
* @type integer
* @minimum 0
*/
cacheRead: number;
/**
* @type integer
* @minimum 0
*/
cacheWrite: number;
/**
* @type integer
* @minimum 0
*/
input: number;
/**
* @type integer
* @minimum 0
*/
output: number;
/**
* @type integer
* @minimum 0
*/
reasoning: number;
}
export interface SpantypesTraceAISummaryDTO {
tokens: SpantypesTraceAITokensDTO;
/**
* @type number
*/
totalCost?: number;
}
export interface SpantypesGettableTraceSummaryDTO {
ai?: SpantypesTraceAISummaryDTO;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMissingSpans: boolean;
/**
* @type string
*/
rootServiceEntryPoint: string;
/**
* @type string
*/
rootServiceName: string;
/**
* @type string
*/
rootSpanStatusCode: string;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount: number;
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
@@ -12882,6 +13099,17 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceSummaryPathParameters = {
traceID: string;
};
export type GetTraceSummary200 = {
data: SpantypesGettableTraceSummaryDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array
@@ -13783,6 +14011,36 @@ export type GetUsersByRoleID200 = {
status: string;
};
export type ListRuleViews200 = {
data: RuletypesListableRuleViewsDTO;
/**
* @type string
*/
status: string;
};
export type CreateRuleView201 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteRuleViewPathParameters = {
id: string;
};
export type UpdateRuleViewPathParameters = {
id: string;
};
export type UpdateRuleView200 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type ListRules200 = {
/**
* @type array

View File

@@ -4,11 +4,17 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -16,6 +22,8 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -27,6 +35,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -127,6 +155,108 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
* @summary Get summary for a trace
*/
export const getTraceSummary = (
{ traceID }: GetTraceSummaryPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceSummary200>({
url: `/api/v1/traces/${traceID}/summary`,
method: 'GET',
signal,
});
};
export const getGetTraceSummaryQueryKey = ({
traceID,
}: GetTraceSummaryPathParameters) => {
return [`/api/v1/traces/${traceID}/summary`] as const;
};
export const getGetTraceSummaryQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
signal,
}) => getTraceSummary({ traceID }, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceSummaryQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceSummary>>
>;
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get summary for a trace
*/
export function useGetTraceSummary<
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get summary for a trace
*/
export const invalidateGetTraceSummary = async (
queryClient: QueryClient,
{ traceID }: GetTraceSummaryPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -1,9 +0,0 @@
import axios from 'api';
import { SemconvMigrationReport } from 'types/api/semconvMigration';
async function getSemconvMigrationReport(): Promise<SemconvMigrationReport> {
const response = await axios.get('/fields/semconv-migration');
return response.data.data;
}
export default getSemconvMigrationReport;

View File

@@ -64,12 +64,12 @@ export const COMMON_FILTERS = {
SERVER_SPANS: "kind_string = 'Server'",
CLIENT_SPANS: "kind_string = 'Client'",
INTERNAL_SPANS: "kind_string = 'Internal'",
ERROR_SPANS: 'http.response.status_code >= 400',
SUCCESS_SPANS: 'http.response.status_code < 400',
ERROR_SPANS: 'http.status_code >= 400',
SUCCESS_SPANS: 'http.status_code < 400',
// Common service filters
EXCLUDE_HEALTH_CHECKS: "http.route != '/health' AND http.route != '/ping'",
HTTP_REQUESTS: "http.request.method != ''",
HTTP_REQUESTS: "http.method != ''",
// Log filters
ERROR_LOGS: "severity_text = 'ERROR'",
@@ -87,7 +87,7 @@ export const COMMON_GROUP_BY_FIELDS = {
fieldContext: 'resource' as const,
},
HTTP_METHOD: {
name: 'http.request.method',
name: 'http.method',
fieldDataType: 'string' as const,
fieldContext: 'attribute' as const,
},
@@ -97,7 +97,7 @@ export const COMMON_GROUP_BY_FIELDS = {
fieldContext: 'attribute' as const,
},
HTTP_STATUS_CODE: {
name: 'http.response.status_code',
name: 'http.status_code',
fieldDataType: 'int64' as const,
fieldContext: 'attribute' as const,
},

View File

@@ -198,6 +198,7 @@ function createBaseSpec(
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
bucketOptions: queryData.bucketOptions,
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {

View File

@@ -145,7 +145,7 @@ function CeleryOverviewConfigOptions(): JSX.Element {
{
placeholder: 'Destination',
queryParam: QueryParams.destination,
filterType: ['messaging.destination.name'],
filterType: ['messaging.destination.name', 'messaging.destination'],
},
{
placeholder: 'Kind',

View File

@@ -5,6 +5,10 @@
border-radius: 2px 0px 0px 2px;
.label {
// Typography.Text takes its display from this token; at its `inline` default
// the label blockifies as a flex item and the text rides the top of the row.
--typography-text-display: flex;
font-size: 12px;
font-style: normal;
font-weight: 500;

View File

@@ -3,7 +3,6 @@ import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { ILog } from 'types/api/logs/log';
import { getSemconvMembers } from 'utils/semconv';
import styles from './LogHighlights.module.scss';
import TraceIdField from './TraceIdField';
@@ -80,9 +79,7 @@ export const LOG_HIGHLIGHTS: LogHighlightConfig[] = [
key: 'environment',
label: 'ENVIRONMENT',
render: (log): ReactNode | null => {
const value = getSemconvMembers('deployment.environment.name')
.map((key) => getAttr(log, key))
.find(Boolean);
const value = getAttr(log, 'deployment.environment');
return value ? valueBadge(value) : null;
},
},

View File

@@ -0,0 +1,142 @@
/**
* Borrows the query builder's control metrics rather than the component defaults the
* toggle group and number input ship with: 36px tall, 2px radius, and the
* `--query-builder-v2-*` surface the selects above this row already paint on.
*/
.bucketOptions {
--toggle-group-radius: var(--radius-1);
--toggle-group-item-size: 36px;
--toggle-group-item-font-size: 13px;
--toggle-group-item-padding-left: var(--spacing-6);
--toggle-group-item-padding-right: var(--spacing-6);
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
// formula, which has no add-ons ancestor to pick the toggle palette up from.
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
--input-height: 36px;
--input-font-size: 13px;
--input-border-radius: var(--radius-1);
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
--input-background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4);
box-sizing: border-box;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: var(--radius-1);
background: var(--query-builder-v2-background-color, var(--l2-background));
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
.field {
display: flex;
align-items: center;
gap: var(--spacing-5);
}
.bounds {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-6);
}
.label {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--l3-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: 0.48px;
text-transform: uppercase;
white-space: nowrap;
}
/**
* The toggle group exposes a font-size token but no family, so each item's label carries
* the query builder's value type itself.
*/
.toggleLabel {
font-family: 'Geist Mono';
font-size: 13px;
font-weight: var(--font-weight-normal);
letter-spacing: -0.07px;
}
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
.numberInput {
width: 104px;
font-family: 'Geist Mono';
letter-spacing: -0.07px;
}
/** Unfilled: a pill on the same row as the toggles above reads as another control,
* and the bounds are derived rather than set here. */
.bound {
padding: var(--spacing-1) 0;
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--query-builder-v2-color, var(--l2-foreground));
}
.overflowBound {
color: var(--l3-foreground);
}
.muted {
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
color: var(--l3-foreground);
}
.hintIcon {
flex: none;
cursor: help;
color: var(--l3-foreground);
}
.closeButton {
margin-left: auto;
width: 24px;
height: 24px;
}

View File

@@ -0,0 +1,279 @@
import { useCallback, useMemo, useState } from 'react';
import { Tooltip } from 'antd';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ChevronUp, Info } from '@signozhq/icons';
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketsKindDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
BUCKET_KIND_HINTS,
BUCKET_KIND_OPTIONS,
DEFAULT_NUM_BUCKETS,
LOG_SCALE_OPTIONS,
MAX_NUM_BUCKETS,
} from './constants';
import {
formatUpperBound,
hasBoundsBeyondPreview,
isLinearBuckets,
kindOptionOf,
linearBuckets,
logBuckets,
logScaleOf,
previewUpperBounds,
} from './utils';
import styles from './BucketOptions.module.scss';
function BucketOptions({
bucketOptions,
unit,
onChange,
onClose,
}: {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
unit?: string;
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
/** Omitted where the section isn't dismissable, as on a formula. */
onClose?: () => void;
}): JSX.Element {
// A linear axis has no bounds to describe until it has a max value, so the picked
// kind is held here rather than read back off the emitted options: it has to survive
// the gap between choosing Linear and filling the field in.
const [kind, setKind] = useState<Querybuildertypesv5BucketsKindDTO>(() =>
kindOptionOf(bucketOptions),
);
const linearSpec =
bucketOptions && isLinearBuckets(bucketOptions)
? bucketOptions.spec
: undefined;
const [logScale, setLogScale] = useState<number>(() =>
logScaleOf(bucketOptions),
);
const [maxValue, setMaxValue] = useState<number | null>(
linearSpec?.maxValue ?? null,
);
const [numBuckets, setNumBuckets] = useState<number | null>(
linearSpec?.numBuckets ?? null,
);
const emitLinear = useCallback(
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
// An incomplete linear axis is sent as no axis at all rather than as a spec the
// request would reject.
if (nextMaxValue === null || nextMaxValue <= 0) {
onChange(undefined);
return;
}
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
},
[onChange],
);
const handleKindChange = useCallback(
(value: string): void => {
// Radix clears the value when the active item is clicked again; a bucket axis is
// always one of the two, so keep the current pick instead.
if (!value) {
return;
}
const nextKind = value as Querybuildertypesv5BucketsKindDTO;
setKind(nextKind);
if (nextKind === Querybuildertypesv5BucketsKindDTO.log) {
onChange(logBuckets(logScale));
} else {
emitLinear(maxValue, numBuckets);
}
},
[emitLinear, logScale, maxValue, numBuckets, onChange],
);
const handleScaleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const nextScale = Number(value);
setLogScale(nextScale);
onChange(logBuckets(nextScale));
},
[onChange],
);
const handleMaxValueChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setMaxValue(next);
emitLinear(next, numBuckets);
},
[emitLinear, numBuckets],
);
const handleNumBucketsChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setNumBuckets(next);
emitLinear(maxValue, next);
},
[emitLinear, maxValue],
);
// The toggle group takes a ReactNode label, which is how each item picks up the
// query builder's value type — the component exposes no font-family token.
const kindItems = useMemo(
() =>
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
const scaleItems = useMemo(
() =>
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
// The kind toggle can be ahead of what has been emitted, so the preview describes
// the picked kind rather than the emitted options.
const previewedOptions = useMemo(():
| Querybuildertypesv5BucketOptionsDTO
| undefined => {
if (kind === Querybuildertypesv5BucketsKindDTO.log) {
return logBuckets(logScale);
}
if (kind === Querybuildertypesv5BucketsKindDTO.linear && maxValue !== null) {
return linearBuckets(maxValue, numBuckets);
}
return undefined;
}, [kind, logScale, maxValue, numBuckets]);
const bounds = useMemo(
() =>
kind === Querybuildertypesv5BucketsKindDTO.linear && !previewedOptions
? undefined
: previewUpperBounds(previewedOptions),
[kind, previewedOptions],
);
return (
<div className={styles.bucketOptions} data-testid="bucket-options">
<div className={styles.controls}>
<div className={styles.field}>
<span className={styles.label}>Bucket by</span>
<ToggleGroupSimple
type="single"
value={kind}
items={kindItems}
onChange={handleKindChange}
testId="bucket-options-kind"
/>
</div>
{kind === Querybuildertypesv5BucketsKindDTO.log && (
<div className={styles.field}>
<span className={styles.label}>Scale</span>
<ToggleGroupSimple
type="single"
value={String(logScale)}
items={scaleItems}
onChange={handleScaleChange}
testId="bucket-options-scale"
/>
</div>
)}
{kind === Querybuildertypesv5BucketsKindDTO.linear && (
<>
<div className={styles.field}>
<span className={styles.label}>Max value</span>
<InputNumber
className={styles.numberInput}
min={0}
value={maxValue}
onChange={handleMaxValueChange}
placeholder="Required"
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
data-testid="bucket-options-max-value"
/>
</div>
<div className={styles.field}>
<span className={styles.label}>Buckets</span>
<InputNumber
className={styles.numberInput}
min={1}
max={MAX_NUM_BUCKETS}
precision={0}
value={numBuckets}
onChange={handleNumBucketsChange}
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
data-testid="bucket-options-num-buckets"
/>
</div>
</>
)}
{onClose && (
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label="Close bucket options"
className={styles.closeButton}
prefix={<ChevronUp size={16} />}
onClick={onClose}
data-testid="bucket-options-close"
/>
)}
</div>
<div className={styles.bounds} data-testid="bucket-options-bounds">
<span className={styles.label}>
Bounds
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
<Info size={12} className={styles.hintIcon} />
</Tooltip>
</span>
{bounds ? (
<>
{bounds.map((bound) => (
<span className={styles.bound} key={bound}>
{formatUpperBound(bound, unit)}
</span>
))}
{hasBoundsBeyondPreview(previewedOptions) && (
<span className={styles.muted}>…</span>
)}
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
</>
) : (
<span className={styles.muted}>Set a max value to see the bounds</span>
)}
</div>
</div>
);
}
BucketOptions.defaultProps = {
bucketOptions: undefined,
unit: undefined,
onClose: undefined,
};
export default BucketOptions;

View File

@@ -0,0 +1,88 @@
import { MAX_LOG_SCALE } from '../constants';
import {
bandsPerDoublingFromScale,
formatUpperBound,
hasBoundsBeyondPreview,
kindOptionOf,
linearBuckets,
logBuckets,
previewUpperBounds,
} from '../utils';
describe('bucket option scales', () => {
it.each([
[-2, 0.25],
[0, 1],
[1, 2],
[2, 4],
[3, 8],
[4, 16],
])('scale %i is %i bands per doubling', (scale, bands) => {
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
});
});
describe('kindOptionOf', () => {
it('reads no options as log, the server default', () => {
expect(kindOptionOf(undefined)).toBe('log');
});
it('reads the kind off the options', () => {
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
});
});
describe('previewUpperBounds', () => {
it('doubles at one band per doubling', () => {
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
1, 2, 4, 8, 16, 32, 64, 128,
]);
});
it('falls back to the finest log axis when no options are set', () => {
const bounds = previewUpperBounds(undefined);
expect(bounds).toHaveLength(8);
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
});
it('spaces a linear axis evenly by bucket width', () => {
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
);
});
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
});
it('has no bounds to preview for a linear axis without a usable max value', () => {
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
});
});
describe('hasBoundsBeyondPreview', () => {
it('is always true for a log axis, which has no top', () => {
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
});
it('tracks whether a linear axis runs past the preview', () => {
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
});
});
describe('formatUpperBound', () => {
it('reads a bound in the panel unit when one is set', () => {
expect(formatUpperBound(1, 'ms')).toContain('ms');
});
it('keeps three significant digits when unitless', () => {
expect(formatUpperBound(128, undefined)).toBe('128');
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
});
});

View File

@@ -0,0 +1,54 @@
import { Querybuildertypesv5BucketsKindDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
* builder keeps its own copy so an out-of-range axis is refused before the request
* rather than after it.
*/
/**
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
* resolution ClickHouse buckets at, so it is both the finest available and what an
* absent `bucketOptions` resolves to.
*/
export const MAX_LOG_SCALE = 4;
/** One band per 16x, the coarsest axis worth rendering. */
export const MIN_LOG_SCALE = -4;
export const MAX_NUM_BUCKETS = 512;
export const DEFAULT_NUM_BUCKETS = 60;
/** Every scale the request accepts, coarsest first. The bounds strip below says
* how coarse a given one is, so the numbers stand alone. */
export const LOG_SCALES = Array.from(
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
(_, index) => MIN_LOG_SCALE + index,
);
/** How many leading upper bounds the bounds strip previews before eliding. */
export const PREVIEW_BOUND_COUNT = 8;
export const BUCKET_KIND_OPTIONS: {
value: Querybuildertypesv5BucketsKindDTO;
label: string;
}[] = [
{ value: Querybuildertypesv5BucketsKindDTO.log, label: 'Log' },
{ value: Querybuildertypesv5BucketsKindDTO.linear, label: 'Linear' },
];
export const LOG_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
value: String(scale),
label: String(scale),
}));
export const BUCKET_KIND_HINTS: Record<
Querybuildertypesv5BucketsKindDTO,
string
> = {
[Querybuildertypesv5BucketsKindDTO.log]:
'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. A lower scale means fewer, coarser bands; scale 4, the finest, is the default.',
[Querybuildertypesv5BucketsKindDTO.linear]:
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
};

View File

@@ -0,0 +1,120 @@
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketOptionsLinearDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTO,
Querybuildertypesv5BucketOptionsLogDTOKind,
Querybuildertypesv5BucketsKindDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import {
DEFAULT_NUM_BUCKETS,
MAX_LOG_SCALE,
PREVIEW_BOUND_COUNT,
} from './constants';
export const isLinearBuckets = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
export const linearBuckets = (
maxValue: number,
numBuckets: number | null,
): Querybuildertypesv5BucketOptionsLinearDTO => ({
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
});
export const logBuckets = (
scale: number,
): Querybuildertypesv5BucketOptionsLogDTO => ({
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale },
});
/**
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
* no scale, so it reads as the default too.
*/
export const logScaleOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number =>
bucketOptions && !isLinearBuckets(bucketOptions)
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
: MAX_LOG_SCALE;
/** A log axis places `2^scale` bounds per doubling. */
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
export const kindOptionOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): Querybuildertypesv5BucketsKindDTO =>
bucketOptions && isLinearBuckets(bucketOptions)
? Querybuildertypesv5BucketsKindDTO.linear
: Querybuildertypesv5BucketsKindDTO.log;
/**
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
* index 0's boundary — and a linear one at the top of its first band; both continue
* past what the strip shows, and everything above the last one lands in the overflow
* band the UI labels separately.
*
* `undefined` for a linear axis with no max value yet: without a top there is nothing
* to divide.
*/
export function previewUpperBounds(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number[] | undefined {
if (bucketOptions && isLinearBuckets(bucketOptions)) {
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
return undefined;
}
const width = maxValue / numBuckets;
return Array.from(
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
(_, index) => (index + 1) * width,
);
}
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
return Array.from(
{ length: PREVIEW_BOUND_COUNT },
(_, index) => 2 ** (index / bands),
);
}
/**
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
* buckets than the strip holds ends where the strip does.
*/
export function hasBoundsBeyondPreview(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): boolean {
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
return true;
}
return (
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
);
}
/**
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
* log axis distinguishable without printing the float in full.
*/
export function formatUpperBound(bound: number, unit?: string): string {
if (unit) {
return getYAxisFormattedValue(String(bound), unit);
}
return Number(bound.toPrecision(3)).toString();
}

View File

@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
import {
BarChart,
ChevronUp,
ExternalLink,
Grid3X3,
ScrollText,
} from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -25,6 +32,7 @@ import {
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
[QueryBuilderField.BucketOptions]: 'bucketOptions',
};
const ADD_ONS: AddOn[] = [
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const BUCKET_OPTIONS: AddOn = {
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
key: QueryBuilderField.BucketOptions,
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
@@ -196,7 +221,7 @@ function QueryAddOns({
isForTraceOperator,
});
const { handleSetQueryData } = useQueryBuilder();
const { handleSetQueryData, currentQuery } = useQueryBuilder();
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
@@ -210,8 +235,25 @@ function QueryAddOns({
addOns = [...ADD_ONS];
}
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
}, [panelType, query.dataSource, showReduceTo]);
if (showReduceTo) {
addOns = [...addOns, REDUCE_TO];
}
if (
panelType === PANEL_TYPES.HEATMAP &&
query.dataSource === DataSource.METRICS &&
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
) {
addOns = [...addOns, BUCKET_OPTIONS];
}
return addOns;
}, [
panelType,
query.dataSource,
query.aggregateAttribute?.type,
showReduceTo,
]);
const resolvedFields = useMemo(
() =>
@@ -392,6 +434,13 @@ function QueryAddOns({
[handleChangeQueryData],
);
const handleChangeBucketOptions = useCallback(
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
handleChangeQueryData('bucketOptions', value);
},
[handleChangeQueryData],
);
return (
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
@@ -559,6 +608,19 @@ function QueryAddOns({
/>
</div>
)}
{selectedViews.find(
(view) => view.key === QueryBuilderField.BucketOptions,
) && (
<div className="add-on-content" data-testid="bucket-options-content">
<BucketOptions
bucketOptions={query.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
/>
</div>
)}
</div>
)}

View File

@@ -1758,7 +1758,7 @@ QuerySearch.defaultProps = {
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.response.status_code >= 500 AND service.name = 'frontend')",
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
};

View File

@@ -1,4 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import {
fireEvent,
render,
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: { unit: string | undefined };
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: { unit: undefined },
}),
}));
@@ -330,4 +332,175 @@ describe('QueryAddOns', () => {
expect.anything(),
);
});
describe('bucket options', () => {
function renderHeatmap(overrides: Partial<any> = {}): void {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
}
it('is offered on a metrics heatmap only', () => {
renderHeatmap();
expect(
screen.getByTestId('query-add-on-bucket_options'),
).toBeInTheDocument();
});
it('is not offered on other panel types', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it('is not offered on a heatmap over another signal', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.LOGS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
'is not offered for a %s metric, which carries its own buckets',
(type) => {
renderHeatmap({
aggregateAttribute: { key: 'http_duration_bucket', type },
});
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
},
);
it("auto-opens on the query's own kind", () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('shows the server default, log at scale 4, without sending it', async () => {
const user = userEvent.setup();
renderHeatmap();
await user.click(screen.getByTestId('query-add-on-bucket_options'));
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '4' })).toBeChecked();
expect(
screen.queryByRole('radio', { name: 'Auto' }),
).not.toBeInTheDocument();
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
'bucketOptions',
expect.anything(),
);
});
it('sends the picked scale', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
it('offers the coarser scales the request accepts below one band per doubling', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: '-4' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: -4 },
});
});
it('previews the bounds the picked axis will carry', () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
const bounds = within(screen.getByTestId('bucket-options-bounds'));
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
expect(bounds.getByText(bound)).toBeInTheDocument();
});
});
it('sends nothing for a linear axis until it has a max value', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
undefined,
);
expect(
screen.getByText('Set a max value to see the bounds'),
).toBeInTheDocument();
});
it('sends the linear axis once a max value is filled in', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
await waitFor(() => {
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
{ kind: 'linear', spec: { maxValue: 500 } },
);
});
});
it('closes back to the toggle bar', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByTestId('bucket-options-close'));
expect(
screen.queryByTestId('bucket-options-content'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -995,16 +995,6 @@ describe('removeKeysFromExpression', () => {
expect(result).toBe("status = 'success'");
});
it('should remove a comparison that uses the exact field wrapper', () => {
const expression =
"exact(resource.deployment.environment) EXISTS AND service.name = 'api-gateway'";
const result = removeKeysFromExpression(expression, [
'resource.deployment.environment',
]);
expect(result).toBe("service.name = 'api-gateway'");
});
it('should remove multiple keys from expression', () => {
const expression =
"service.name = 'api-gateway' AND status = 'success' AND region = 'us-east-1'";

View File

@@ -16,6 +16,7 @@ export enum QueryBuilderField {
Limit = 'limit',
Legend = 'legend_format',
ReduceTo = 'reduce_to',
BucketOptions = 'bucket_options',
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',

View File

@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
[QueryBuilderField.Limit]: { state: 'hidden' },
[QueryBuilderField.Legend]: { state: 'hidden' },
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
[QueryBuilderField.Formula]: { state: 'hidden' },
[QueryBuilderField.OrderBy]: { state: 'pinned' },
};

View File

@@ -687,16 +687,7 @@ export const removeKeysFromExpression = (
}
function visitComparison(ctx: ComparisonContext): string | null {
const field = ctx.field();
// The runtime returns null for the inactive field alternative even though
// the generated TypeScript signature is non-nullable.
const exactCall = field.exactCall() as unknown as ReturnType<
typeof field.exactCall
> | null;
const keyText = (exactCall ? exactCall.key() : field.key())
.getText()
.trim()
.toLowerCase();
const keyText = ctx.key().getText().trim().toLowerCase();
if (!keysSet.has(keyText)) {
return src(ctx);

View File

@@ -5,7 +5,7 @@ import { ArrowUpRight } from '@signozhq/icons';
const QUICK_FILTER_DOC_PATHS: Record<string, string> = {
severity_text: 'severity-text',
'deployment.environment.name': 'environment',
'deployment.environment': 'environment',
'service.name': 'service-name',
'host.name': 'hostname',
'k8s.cluster.name': 'k8s-cluster-name',

View File

@@ -12,6 +12,7 @@
overflow: visible;
position: relative;
align-self: stretch;
clip-path: inset(0 -100vw 0 0);
}
}

View File

@@ -57,6 +57,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
@@ -379,6 +380,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}

View File

@@ -8,9 +8,7 @@
overflow: hidden;
}
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
.sidebar {
width: 280px;
flex-shrink: 0;
display: flex;
@@ -21,6 +19,44 @@
z-index: 2;
}
.savedViews {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
}
.quickFilters {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--l1-background);
transition: transform 0.2s ease-in-out;
z-index: 1;
&.isOpen {
transform: translateX(100%);
}
}
// No quick filters: the header sits in flow and the list stays under it
.isStatic {
border-right: 1px solid var(--l1-border);
.quickFilters {
position: static;
flex-shrink: 0;
}
.savedViews {
border-right: 0;
}
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
@@ -31,3 +67,7 @@
flex-direction: column;
overflow: hidden;
}
.contentInner {
height: 100%;
}

View File

@@ -1,6 +1,9 @@
import { ComponentProps, ReactNode } from 'react';
import { ComponentProps, ReactNode, useState } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -12,8 +15,15 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
type SavedViewsElementProps = Omit<
ComponentProps<typeof SavedViewsPanel>,
'onClose'
>;
export interface QuickFiltersLayoutProps {
quickFilterProps: QuickFiltersElementProps;
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
@@ -23,20 +33,60 @@ export interface QuickFiltersLayoutProps {
function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsOpen, setIsViewsOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isPanelPinned = hasSavedViews && !hasQuickFilters;
const isSliding = hasQuickFilters && isViewsOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showFilters && (
{showSidebar && (
<aside
className={styles.filters}
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
data-testid="quick-filters-layout-filters"
>
<QuickFilters {...quickFilterProps} />
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
<SavedViewsPanel
{...savedViewProps}
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
/>
)}
</div>
</aside>
)}
<section
@@ -44,7 +94,7 @@ function QuickFiltersLayout({
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div>{children}</div>
<div className={styles.contentInner}>{children}</div>
</OverlayScrollbar>
</section>
</div>

View File

@@ -1,4 +1,6 @@
import { render, screen } from 'tests/test-utils';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -7,15 +9,33 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
filters: 'filters',
sidebar: 'sidebar',
isStatic: 'isStatic',
savedViews: 'savedViews',
quickFilters: 'quickFilters',
isOpen: 'isOpen',
content: 'content',
contentInner: 'contentInner',
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
default: ({
source,
savedViewsHeader,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
</div>
),
}));
@@ -23,8 +43,16 @@ const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -40,11 +68,16 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<QuickFiltersLayout
showFilters={false}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
@@ -56,24 +89,138 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByText('content')).toBeInTheDocument();
});
it('renders no sidebar at all without quick filters or saved views', () => {
render(
<QuickFiltersLayout showFilters>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="page-root"
contentClassName="page-content"
testId="page"
className="root-extra"
contentClassName="content-extra"
testId="layout"
>
<div>content</div>
</QuickFiltersLayout>,
);
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'page-content',
'content-extra',
);
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const quickFilters = screen.getByTestId('quick-filters');
expect(quickFilters).toContainElement(
screen.getByTestId('saved-views-header'),
);
expect(drawer()).toContainElement(quickFilters);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const sidebar = screen.getByTestId('quick-filters-layout-filters');
expect(sidebar).toHaveClass('isStatic');
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
});
});

View File

@@ -8,12 +8,20 @@
width: 342px;
height: 100%;
background: var(--l1-background);
transition: width 0.05s ease-in-out;
border-right: 1px solid var(--l1-border);
overflow: hidden;
color: var(--l1-foreground);
transition:
transform 0.2s ease-in-out,
visibility 0s;
&.hidden {
width: 0;
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease-in-out,
visibility 0s 0.2s;
}
.qf-header {
display: flex;

View File

@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -61,6 +62,7 @@ export interface IQuickFiltersProps {
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {

View File

@@ -1,32 +0,0 @@
import { Alert } from 'antd';
import { findOldSemconvNames } from 'utils/semconv';
interface SemconvEditorWarningProps {
value: unknown;
editor: string;
}
function SemconvEditorWarning({
value,
editor,
}: SemconvEditorWarningProps): JSX.Element | null {
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
const renames = findOldSemconvNames(text);
if (renames.length === 0) {
return null;
}
return (
<Alert
type="warning"
showIcon
data-testid="semconv-editor-warning"
message={`${editor} contains renamed OpenTelemetry fields`}
description={renames
.map(({ old, current }) => `${old} → ${current}`)
.join(', ')}
/>
);
}
export default SemconvEditorWarning;

View File

@@ -1,23 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { getSemconvRename } from 'utils/semconv';
interface SemconvOldNameBadgeProps {
name: string;
}
function SemconvOldNameBadge({
name,
}: SemconvOldNameBadgeProps): JSX.Element | null {
const rename = getSemconvRename(name);
if (!rename || rename.family.kind !== 'attribute') {
return null;
}
return (
<Badge color="amber" variant="outline" data-testid="semconv-old-name-badge">
old name, renamed to {rename.current}
</Badge>
);
}
export default SemconvOldNameBadge;

View File

@@ -1,39 +0,0 @@
import { render, screen } from '@testing-library/react';
import SemconvEditorWarning from '../SemconvEditorWarning';
import SemconvOldNameBadge from '../SemconvOldNameBadge';
describe('semantic convention product hints', () => {
it('badges an old raw attribute with its current name', () => {
render(<SemconvOldNameBadge name="deployment.environment" />);
expect(screen.getByTestId('semconv-old-name-badge')).toHaveTextContent(
'old name, renamed to deployment.environment.name',
);
});
it('does not badge a current raw attribute', () => {
render(<SemconvOldNameBadge name="deployment.environment.name" />);
expect(
screen.queryByTestId('semconv-old-name-badge'),
).not.toBeInTheDocument();
});
it('shows an informational editor warning without disabling the editor', () => {
render(
<>
<input aria-label="query" defaultValue="deployment.environment = 'prod'" />
<SemconvEditorWarning
value="deployment.environment = 'prod'"
editor="ClickHouse SQL"
/>
</>,
);
expect(screen.getByLabelText('query')).not.toBeDisabled();
expect(screen.getByTestId('semconv-editor-warning')).toHaveTextContent(
'deployment.environment → deployment.environment.name',
);
});
});

View File

@@ -1,2 +0,0 @@
export { default as SemconvEditorWarning } from './SemconvEditorWarning';
export { default as SemconvOldNameBadge } from './SemconvOldNameBadge';

View File

@@ -19,51 +19,6 @@ export type SemconvFamily = {
};
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
{
current: 'code.file.path',
kind: 'attribute',
members: [
{
name: 'code.filepath',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'code.function.name',
kind: 'attribute',
members: [
{
name: 'code.function',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'code.line.number',
kind: 'attribute',
members: [
{
name: 'code.lineno',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'container.cpu.usage',
kind: 'metric',
@@ -79,84 +34,6 @@ export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
signals: [],
valueMap: {},
},
{
current: 'container.runtime.name',
kind: 'attribute',
members: [
{
name: 'container.runtime',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'db.namespace',
kind: 'attribute',
members: [
{
name: 'db.elasticsearch.cluster.name',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
{
name: 'db.name',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
{
name: 'db.cassandra.keyspace',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
{
name: 'db.hbase.namespace',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'db.operation.name',
kind: 'attribute',
members: [
{
name: 'db.operation',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'db.query.text',
kind: 'attribute',
members: [
{
name: 'db.statement',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'deployment.environment.name',
kind: 'attribute',
@@ -172,36 +49,6 @@ export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
signals: ['logs', 'metrics', 'traces'],
valueMap: {},
},
{
current: 'http.request.method',
kind: 'attribute',
members: [
{
name: 'http.method',
contexts: ['attribute'],
signals: ['logs', 'traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'http.response.status_code',
kind: 'attribute',
members: [
{
name: 'http.status_code',
contexts: ['attribute'],
signals: ['logs', 'traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'k8s.node.cpu.usage',
kind: 'metric',
@@ -232,175 +79,4 @@ export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
signals: [],
valueMap: {},
},
{
current: 'messaging.client.id',
kind: 'attribute',
members: [
{
name: 'messaging.client_id',
contexts: ['attribute'],
signals: ['metrics', 'traces'],
applyToMetrics: [],
},
{
name: 'messaging.kafka.client_id',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
{
name: 'messaging.rocketmq.client_id',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'messaging.consumer.group.name',
kind: 'attribute',
members: [
{
name: 'messaging.eventhubs.consumer.group',
contexts: [],
signals: [],
applyToMetrics: [],
},
{
name: 'messaging.kafka.consumer.group',
contexts: [],
signals: [],
applyToMetrics: [],
},
{
name: 'messaging.rocketmq.client_group',
contexts: [],
signals: [],
applyToMetrics: [],
},
{
name: 'messaging.kafka.consumer_group',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'messaging.destination.name',
kind: 'attribute',
members: [
{
name: 'messaging.destination',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'messaging.operation.type',
kind: 'attribute',
members: [
{
name: 'messaging.operation',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'rpc.system.name',
kind: 'attribute',
members: [
{
name: 'rpc.system',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'service.peer.name',
kind: 'attribute',
members: [
{
name: 'peer.service',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'url.full',
kind: 'attribute',
members: [
{
name: 'http.url',
contexts: ['attribute'],
signals: ['logs', 'traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'url.scheme',
kind: 'attribute',
members: [
{
name: 'http.scheme',
contexts: ['attribute'],
signals: ['logs', 'traces'],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'user_agent.original',
kind: 'attribute',
members: [
{
name: 'browser.user_agent',
contexts: ['resource'],
signals: [],
applyToMetrics: [],
},
{
name: 'http.user_agent',
contexts: ['attribute'],
signals: ['traces'],
applyToMetrics: [],
},
],
contexts: ['attribute'],
signals: ['logs', 'traces'],
valueMap: {},
},
] as const;

View File

@@ -32,6 +32,8 @@ export const getComponentForPanelType = (
[PANEL_TYPES.AREA]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,

View File

@@ -339,6 +339,7 @@ export enum PANEL_TYPES {
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
},
];
/**
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
* count already draws. Sum is also what the statement builder forces on a histogram
* heatmap whatever is asked for, so it is the only honest option to offer.
*/
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
string,
string
>[] = [
{
value: MetricAggregateOperator.COUNT,
label: 'Count',
},
];
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
string,
string

View File

@@ -10,7 +10,7 @@ export const AllTraceFilterKeyValue: Record<string, string> = {
durationNano: 'Duration',
duration_nano: 'Duration',
durationNanoMax: 'Duration',
'deployment.environment.name': 'Environment',
'deployment.environment': 'Environment',
hasError: 'Status',
has_error: 'Status',
serviceName: 'Service Name',
@@ -55,11 +55,11 @@ export const traceFilterKeys: Record<AllTraceFilterKeys, BaseAutocompleteData> =
id: 'serviceName--string--tag--true',
},
'deployment.environment.name': {
key: 'deployment.environment.name',
'deployment.environment': {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
id: 'deployment.environment.name--string--resource--false',
id: 'deployment.environment--string--resource--false',
},
name: {
key: 'name',

View File

@@ -155,7 +155,7 @@ function DomainList(): JSX.Element {
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
placeholder="Enter your filter query (e.g., deployment.environment.name = 'otel-demo' AND service.name = 'frontend')"
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>
@@ -180,8 +180,9 @@ function DomainList(): JSX.Element {
</div>
<div className="no-domain-subtitle">
Ensure all HTTP client spans are being sent with kind as{' '}
<span className="attribute">Client</span> and the URL set in the{' '}
<span className="attribute">url.full</span> attribute.
<span className="attribute">Client</span> and url set in{' '}
<span className="attribute">url.full</span> or{' '}
<span className="attribute">http.url</span> attribute.
</div>
<a
href={DOCLINKS.EXTERNAL_API_MONITORING}

View File

@@ -6,9 +6,9 @@ import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] =
[
{
label: 'deployment.environment.name',
label: 'deployment.environment',
type: 'resource',
name: 'deployment.environment.name',
name: 'deployment.environment',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},

View File

@@ -87,7 +87,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
title: 'Environment',
attributeKey: {
key: 'deployment.environment.name',
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},

View File

@@ -467,6 +467,7 @@ describe('Footer utils', () => {
timeAggregation: 'avg',
},
],
bucketOptions: undefined,
disabled: false,
filter: {
expression: '',

View File

@@ -11,7 +11,6 @@ import {
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { getSemconvMembers } from 'utils/semconv';
import styles from './traceListColumns.module.scss';
const keyToLabelMap: Record<string, string> = {
@@ -28,12 +27,7 @@ const keyToLabelMap: Record<string, string> = {
const keyAliases: Record<string, string[]> = {
serviceName: ['serviceName', 'service.name', 'service_name'],
durationNano: ['durationNano', 'duration.nano', 'duration_nano'],
httpMethod: [
'httpMethod',
...getSemconvMembers('http.request.method'),
'http_request_method',
'http_method',
],
httpMethod: ['httpMethod', 'http.method', 'http_method'],
responseStatusCode: [
'response_status_code',
'response.status.code',

View File

@@ -128,7 +128,7 @@ export const INFRA_MONITORING_ATTR_KEYS = {
K8S_OBJECT_NAME: 'k8s.object.name',
// Environment
DEPLOYMENT_ENVIRONMENT: 'deployment.environment.name',
DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
// Host System
OS_TYPE: 'os.type',
@@ -907,7 +907,7 @@ export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
[InfraMonitoringEntity.NAMESPACES]:
"Enter your filter query (e.g., k8s.namespace.name = 'production' AND k8s.cluster.name = 'prod-cluster')",
[InfraMonitoringEntity.CLUSTERS]:
"Enter your filter query (e.g., k8s.cluster.name = 'prod-cluster' AND deployment.environment.name = 'production')",
"Enter your filter query (e.g., k8s.cluster.name = 'prod-cluster' AND deployment.environment = 'production')",
[InfraMonitoringEntity.DEPLOYMENTS]:
"Enter your filter query (e.g., k8s.deployment.name = 'api-server' AND k8s.namespace.name = 'production')",
[InfraMonitoringEntity.STATEFULSETS]:

View File

@@ -2,17 +2,6 @@
color: white;
}
.semconv-migration-report {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 12px;
.ant-table-wrapper {
margin-top: 4px;
}
}
.ingestion-key-container {
margin-top: 24px;
display: flex;

View File

@@ -5,8 +5,6 @@ import getIngestionData from 'api/settings/getIngestionData';
import { useAppContext } from 'providers/App/App';
import { IngestionDataType } from 'types/api/settings/ingestion';
import SemconvMigrationReport from './SemconvMigrationReport';
import './IngestionSettings.styles.scss';
export default function IngestionSettings(): JSX.Element {
@@ -86,7 +84,6 @@ export default function IngestionSettings(): JSX.Element {
dataSource={data}
bordered
/>
<SemconvMigrationReport />
</div>
);
}

View File

@@ -83,8 +83,6 @@ import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
import SemconvMigrationReport from './SemconvMigrationReport';
import './IngestionSettings.styles.scss';
const { Option } = Select;
@@ -1707,7 +1705,6 @@ function MultiIngestionSettings(): JSX.Element {
}}
className="ingestion-keys-table"
/>
<SemconvMigrationReport />
</div>
{/* Delete Key Modal */}

View File

@@ -1,72 +0,0 @@
import { useQuery } from 'react-query';
import { Alert, Table, TableColumnsType } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getSemconvMigrationReport from 'api/semconv/getMigrationReport';
import dayjs from 'dayjs';
import { SemconvMigrationReportEntry } from 'types/api/semconvMigration';
function SemconvMigrationReport(): JSX.Element {
const { data, isLoading, isError } = useQuery({
queryKey: ['semconv-migration-report'],
queryFn: getSemconvMigrationReport,
});
const columns: TableColumnsType<SemconvMigrationReportEntry> = [
{
title: 'Old name',
dataIndex: 'old',
key: 'old',
},
{
title: 'Current name',
dataIndex: 'current',
key: 'current',
},
{
title: 'Signal',
dataIndex: 'signal',
key: 'signal',
},
{
title: 'Services still sending only the old name',
dataIndex: 'services',
key: 'services',
render: (services: string[]): string => services.join(', '),
},
{
title: 'Last seen',
dataIndex: 'lastSeenUnixMilli',
key: 'lastSeenUnixMilli',
render: (value: number): string =>
dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<section className="semconv-migration-report">
<Typography.Title level={4}>Semantic convention migration</Typography.Title>
<Typography.Text>
Services in this report sent an old OpenTelemetry field during the last 24
hours without sending its current replacement. Update their SDK or
instrumentation when practical; SigNoz queries remain backward compatible.
</Typography.Text>
{isError && (
<Alert
type="error"
showIcon
message="Could not load the semantic convention migration report"
/>
)}
<Table
loading={isLoading}
columns={columns}
dataSource={data?.entries ?? []}
rowKey={(entry): string => `${entry.current}-${entry.old}-${entry.signal}`}
pagination={false}
locale={{ emptyText: 'No old-only services found in the last 24 hours' }}
/>
</section>
);
}
export default SemconvMigrationReport;

View File

@@ -18,7 +18,7 @@ export const SAMPLE_SPAN_JSON = `{
},
"resource": {
"service.name": "llm-gateway",
"deployment.environment.name": "production"
"deployment.environment": "production"
}
}`;

View File

@@ -1,5 +1,3 @@
import { getSemconvMembers } from 'utils/semconv';
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
@@ -14,10 +12,12 @@ export const TIMESTAMP_FIELD_NAMES = new Set([
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
...getSemconvMembers('http.request.method'),
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
...getSemconvMembers('http.response.status_code'),
'http.status_code',
'http.response.status_code',
]);
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.

View File

@@ -74,8 +74,7 @@ export function tracesRunQueryAction(
properties: {
key: {
type: 'string',
description:
'Attribute key, e.g. service.name, http.response.status_code',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
@@ -144,7 +143,7 @@ export function tracesAddFilterAction(
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.response.status_code',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',

View File

@@ -5,22 +5,9 @@
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
margin-top: var(--spacing-4);
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
@@ -80,13 +80,10 @@ function UnpricedModelsTab(): JSX.Element {
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
<Callout type="warning" size="small" showIcon className={styles.banner}>
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Callout>
{isError && (
<div className={styles.error}>

View File

@@ -1,7 +1,6 @@
import { Divider } from '@signozhq/ui/divider';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { SemconvOldNameBadge } from 'components/Semconv';
import { TagContainer, TagLabel, TagValue } from './FieldRenderer.styles';
import { FieldRendererProps } from './LogDetailedView.types';
@@ -29,7 +28,6 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
<Typography.Text truncate={1} className="label">
{newField}{' '}
</Typography.Text>
<SemconvOldNameBadge name={newField} />
</TooltipSimple>
<div className="tags">
@@ -49,10 +47,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
</div>
</>
) : (
<>
<span className="label">{field}</span>
<SemconvOldNameBadge name={field} />
</>
<span className="label">{field}</span>
)}
</span>
);

View File

@@ -164,9 +164,7 @@ describe('useInitialQuery - Priority-Based Resource Filtering', () => {
value: 'frontend-service',
}),
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment.name',
}),
key: expect.objectContaining({ key: 'deployment.environment' }),
value: 'production',
}),
expect.objectContaining({
@@ -288,9 +286,7 @@ describe('useInitialQuery - Priority-Based Resource Filtering', () => {
value: 'legacy-app',
}),
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment.name',
}),
key: expect.objectContaining({ key: 'deployment.environment' }),
value: 'production',
}),
expect.objectContaining({

View File

@@ -6,14 +6,13 @@ import {
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { getSemconvRename } from 'utils/semconv';
const FALLBACK_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
const FALLBACK_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the specified keywords
// Priority categories for filter selection
// Strategy:
// - Always include: service.name, deployment.environment.name, env, environment
// - Always include: service.name, deployment.environment, env, environment
// - Select ONE category only: stops at the first category with a matching attribute
// - Within category: picks the first available attribute by order
// - Order (highest to lowest priority): Kubernetes > Cloud > Host > Container
@@ -27,36 +26,27 @@ const PRIORITY_CATEGORIES = [
const SERVICE_AND_ENVIRONMENT_KEYS = [
'service.name',
'deployment.environment.name',
'deployment.environment',
'env',
'environment',
];
export const getFiltersFromResources = (
resources: ILog['resources_string'],
): TagFilterItem[] => {
const items = new Map<string, TagFilterItem>();
Object.keys(resources).forEach((key: string) => {
const currentKey = getSemconvRename(key)?.current ?? key;
): TagFilterItem[] =>
Object.keys(resources).map((key: string) => {
const resourceValue = resources[key] as string;
const item = {
return {
id: uuid(),
key: {
key: currentKey,
key,
dataType: DataTypes.String,
type: 'resource',
},
op: OPERATORS['='],
value: resourceValue,
};
// If raw data contains both names, retain the current value just like the
// backend's current-first resolver.
if (!items.has(currentKey) || key === currentKey) {
items.set(currentKey, item);
}
});
return Array.from(items.values());
};
export const isServiceOrEnvironmentAttribute = (key: string): boolean =>
SERVICE_AND_ENVIRONMENT_KEYS.includes(key);

View File

@@ -28,7 +28,7 @@ import { v4 as uuid } from 'uuid';
export const dbSystemTags: Tags[] = [
{
Key: 'db.system.name.(string)',
Key: 'db.system.(string)',
StringValues: [''],
NumberValues: [],
BoolValues: [],

View File

@@ -10,6 +10,8 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -347,57 +349,62 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
<QuickFiltersLayout
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</Tooltip>
</div>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
</QuickFiltersLayout>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}

View File

@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import { SemconvEditorWarning } from 'components/Semconv';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import isEqual from 'lodash-es/isEqual';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -56,7 +55,6 @@ function TagFilterInputWithLogsResultPreview({
value={value}
onChange={onChange}
/>
<SemconvEditorWarning value={value} editor="Pipeline filter" />
<div className="pipeline-filter-input-preview-container">
<LogsFilterPreview filter={value} />
</div>

View File

@@ -0,0 +1,79 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { DataSourceDropdown } from '../DataSourceDropdown';
const TEST_ID = 'query-data-source-selector';
async function openDropdown(): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(TEST_ID);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
}
describe('DataSourceDropdown', () => {
// antd's virtual list renders only the first couple of options into jsdom, so
// each case asserts what the restriction admits and excludes, not the full list.
it('offers the signals beyond the current one when nothing restricts it', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
});
it('offers only the signals the caller can visualize', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Metrics' }),
).resolves.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Logs' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Traces' }),
).not.toBeInTheDocument();
});
it('drops a signal that is not a data source a query can be built against', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.LOGS}
allowedDataSources={[
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO[''],
]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Metrics' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,9 @@
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -24,8 +26,12 @@ export function Formula({
query,
isQBV2,
}: FormulaProps): JSX.Element {
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
useQueryBuilder();
const {
removeQueryBuilderEntityByIndex,
handleSetFormulaData,
panelType,
currentQuery,
} = useQueryBuilder();
const { handleChangeFormulaData } = useQueryOperations({
index,
@@ -73,6 +79,13 @@ export function Formula({
[handleChangeFormulaData],
);
const handleChangeBucketOptions = useCallback(
(value: IBuilderFormula['bucketOptions']) => {
handleChangeFormulaData('bucketOptions', value);
},
[handleChangeFormulaData],
);
const handleQBV2OrderByChange = useCallback(
(value: string) => {
const [columnName, order] = value.split(' ');
@@ -163,6 +176,17 @@ export function Formula({
</div>
</Col>
)}
{/* A heatmap draws its one enabled query, which is the formula when its
inputs are disabled — so the formula states its own bucket axis. */}
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
<Col span={24}>
<BucketOptions
bucketOptions={formula.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
/>
</Col>
)}
</Row>
)}
</Row>

View File

@@ -0,0 +1,91 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
import { Formula } from '../Formula';
const mockHandleChangeFormulaData = jest.fn();
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
removeQueryBuilderEntityByIndex: jest.fn(),
handleSetFormulaData: jest.fn(),
panelType: mockPanelType,
currentQuery: { unit: undefined },
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): Record<string, unknown> => ({
handleChangeFormulaData: mockHandleChangeFormulaData,
}),
}));
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
}));
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
return {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
...overrides,
};
}
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
render(
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
);
}
describe('Formula bucket options', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPanelType = PANEL_TYPES.HEATMAP;
});
it('offers a bucket axis on a heatmap', () => {
renderFormula();
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
});
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
renderFormula();
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
});
it('offers no bucket axis on other panel types', () => {
mockPanelType = PANEL_TYPES.TIME_SERIES;
renderFormula();
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
});
it('opens on the axis the formula already carries', () => {
renderFormula({
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
});
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('writes the picked axis onto the formula', async () => {
const user = userEvent.setup();
renderFormula();
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
});

View File

@@ -2,7 +2,6 @@ import { ChangeEvent, useCallback } from 'react';
import MEditor, { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Input } from 'antd';
import { SemconvEditorWarning } from 'components/Semconv';
import { LEGEND } from 'constants/global';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -119,7 +118,6 @@ function ClickHouseQueryBuilder({
theme={isDarkMode ? 'my-theme' : 'light'}
beforeMount={setEditorTheme}
/>
<SemconvEditorWarning value={queryData?.query} editor="ClickHouse SQL" />
<Input
onChange={handleUpdateInput}
name="legend"

View File

@@ -1,7 +1,6 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { LEGEND } from 'constants/global';
import { SemconvEditorWarning } from 'components/Semconv';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IPromQLQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -67,7 +66,6 @@ function PromQLQueryBuilder({
style={{ marginBottom: '0.5rem' }}
data-testid="promql-query-input"
/>
<SemconvEditorWarning value={queryData?.query} editor="PromQL" />
<Input
onChange={handleUpdateQuery}

View File

@@ -155,6 +155,39 @@ describe('ResetPassword Component', () => {
{ timeout: 200 },
);
});
it('clears password mismatch error when confirm password is emptied', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'password123');
await user.type(confirmPasswordInput, 'password456');
await user.tab();
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
});
await user.clear(confirmPasswordInput);
await waitFor(() => {
expect(
screen.queryByText(/passwords don't match/i),
).not.toBeInTheDocument();
expect(submitButton).toBeDisabled();
});
});
});
describe('Successful Password Reset', () => {
@@ -288,6 +321,65 @@ describe('ResetPassword Component', () => {
expect(screen.queryByText(/invalid token/i)).not.toBeInTheDocument();
});
});
it('clears API error when the user edits the password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'invalid_password',
message: 'password must be at least 8 characters long',
},
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'weak');
await user.type(confirmPasswordInput, 'weak');
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
await user.click(submitButton);
await waitFor(() => {
expect(
screen.getByText(/password must be at least 8 characters long/i),
).toBeInTheDocument();
});
await user.type(passwordInput, 'Er1!');
await waitFor(() => {
expect(
screen.queryByText(/password must be at least 8 characters long/i),
).not.toBeInTheDocument();
});
await user.type(confirmPasswordInput, 'Er1!');
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
});
describe('Loading States', () => {

View File

@@ -10,7 +10,6 @@ import { useResetPassword } from 'api/generated/services/users';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
@@ -23,10 +22,7 @@ import './ResetPassword.styles.scss';
type FormValues = { password: string; confirmPassword: string };
function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [isValidPassword, setIsValidPassword] = useState(false);
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
@@ -37,6 +33,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
mutate: resetPassword,
isLoading,
error: mutationError,
reset: resetMutation,
} = useResetPassword();
const errorMessage = useMemo(
@@ -45,11 +42,30 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
);
const [form] = Form.useForm<FormValues>();
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
const password = Form.useWatch('password', form);
const confirmPassword = Form.useWatch('confirmPassword', form);
const isPasswordMismatch =
Boolean(confirmPassword) && password !== confirmPassword;
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
const isValidPassword =
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
password === confirmPassword;
const handleValuesChange = (): void => {
if (mutationError) {
resetMutation();
}
};
const handleSubmit = (): void => {
if (!token) {
return;
}
resetPassword(
{ data: { password, token: token || '' } },
{ data: { password: form.getFieldValue('password'), token } },
{
onSuccess: (): void => {
notifications.success({
@@ -63,80 +79,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
);
};
const validatePassword = (): boolean => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
confirmPassword &&
password.trim() &&
confirmPassword.trim() &&
password.length > 0 &&
confirmPassword.length > 0
) {
return password === confirmPassword;
}
return false;
};
const handleValuesChange = useDebouncedFn((): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (!password || !confirmPassword) {
setIsValidPassword(false);
}
// Only clear error if passwords match while typing (but don't set error until blur)
if (
password &&
confirmPassword &&
password.trim() &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
// Only clear error if passwords match, don't set error on mismatch
if (isValid) {
setConfirmPasswordError(false);
}
}
}, 100);
const handlePasswordBlur = (): void => {
const { confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword && confirmPassword.trim()) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
password.trim() &&
confirmPassword &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleSubmit = (): void => {
const isValid = validatePassword();
setIsValidPassword(isValid);
if (token) {
handleFormSubmit();
}
};
return (
<AuthPageContainer>
<div className="reset-password-card">
@@ -158,6 +100,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
<FormContainer
form={form}
onFinish={handleSubmit}
onValuesChange={handleValuesChange}
className="reset-password-form"
>
<div className="reset-password-form-container">
@@ -171,8 +114,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
>
<AntdInput.Password
tabIndex={0}
onChange={handleValuesChange}
onBlur={handlePasswordBlur}
id="password"
data-testid="password"
placeholder="Enter new password"
@@ -189,8 +130,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
onChange={handleValuesChange}
onBlur={handleConfirmPasswordBlur}
onBlur={(): void => setConfirmPasswordTouched(true)}
id="confirmPassword"
data-testid="confirmPassword"
placeholder="Confirm your new password"
@@ -201,7 +141,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
</div>
</div>
{confirmPasswordError && (
{showPasswordMismatchError && (
<Callout
type="error"
size="small"
@@ -213,7 +153,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
</Callout>
)}
{errorMessage && !confirmPasswordError && (
{errorMessage && !showPasswordMismatchError && (
<AuthError error={errorMessage} />
)}

View File

@@ -0,0 +1,19 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -0,0 +1,43 @@
import { Menu } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsHeader.module.scss';
// Temp Placeholder data till main functionality is plugged in
const PLACEHOLDER_VIEW_NAME = 'My view';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onOpenViews?: () => void;
}): JSX.Element {
return (
<div
className={styles.header}
data-testid="saved-views-header"
data-source={source}
>
<Typography.Text className={styles.name}>
{PLACEHOLDER_VIEW_NAME}
</Typography.Text>
{onOpenViews && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="All views"
prefix={<Menu size={14} />}
onClick={onOpenViews}
data-testid="saved-views-open"
/>
)}
</div>
);
}
export default SavedViewsHeader;

View File

@@ -0,0 +1,21 @@
.panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: var(--l1-foreground);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -0,0 +1,40 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsPanel.module.scss';
function SavedViewsPanel({
source,
onClose,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onClose?: () => void;
}): JSX.Element {
return (
<div
className={styles.panel}
data-testid="saved-views-panel"
data-source={source}
>
<div className={styles.header}>
<Typography.Text className={styles.title}>All views</Typography.Text>
{onClose && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
onClick={onClose}
data-testid="saved-views-close"
/>
)}
</div>
</div>
);
}
export default SavedViewsPanel;

View File

@@ -1,5 +1,3 @@
import { getSemconvMembers } from 'utils/semconv';
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
@@ -7,10 +5,12 @@ export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
...getSemconvMembers('http.request.method'),
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
...getSemconvMembers('http.response.status_code'),
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);

View File

@@ -30,6 +30,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
AREA: true,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -21,4 +21,6 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.BAR]: BarPanel,
[PANEL_TYPES.AREA]: TimeSeriesPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
};

View File

@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
legend: '',
};
const setupMockQueryBuilder = (): void => {
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
(useQueryBuilder as jest.Mock).mockReturnValue({
handleSetQueryData: mockHandleSetQueryData,
handleSetFormulaData: mockHandleSetFormulaData,
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
setLastUsedQuery: mockSetLastUsedQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
panelType: 'time_series',
panelType,
currentQuery: {
builder: {
queryData: [defaultMockQuery, defaultMockQuery],
@@ -332,4 +332,105 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
);
});
});
describe('spaceAggregationOptions for a histogram metric', () => {
const histogramQuery: IBuilderQuery = {
...defaultMockQuery,
aggregateAttribute: {
key: 'signoz_latency',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
} as BaseAutocompleteData,
};
it('offers the percentiles on a time series panel', () => {
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([
MetricAggregateOperator.P50,
MetricAggregateOperator.P75,
MetricAggregateOperator.P90,
MetricAggregateOperator.P95,
MetricAggregateOperator.P99,
]);
});
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([MetricAggregateOperator.COUNT]);
});
});
describe('picking a histogram metric', () => {
const histogramAttribute: BaseAutocompleteData = {
key: 'http.client.duration.bucket',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
};
it('defaults the spatial aggregation to p90 on a time series panel', () => {
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.P90,
}),
],
}),
);
});
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.COUNT,
}),
],
}),
);
});
it('drops the bucket axis a gauge had chosen, since `le` is the axis now', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({
entityVersion: ENTITY_VERSION_V5,
query: {
...defaultMockQuery,
bucketOptions: { kind: 'log', spec: { scale: 2 } },
},
});
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({ bucketOptions: undefined }),
);
});
});
});

View File

@@ -18,6 +18,7 @@ import {
} from 'constants/queryBuilder';
import {
metricsGaugeSpaceAggregateOperatorOptions,
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
metricsHistogramSpaceAggregateOperatorOptions,
metricsSumSpaceAggregateOperatorOptions,
metricsUnknownSpaceAggregateOperatorOptions,
@@ -176,6 +177,11 @@ export const useQueryOperations: UseQueryOperations = ({
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
});
const histogramSpaceAggregationOptions =
panelType === PANEL_TYPES.HEATMAP
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
: metricsHistogramSpaceAggregateOperatorOptions;
switch (aggregateAttribute?.type) {
case ATTRIBUTE_TYPES.SUM:
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
@@ -185,11 +191,11 @@ export const useQueryOperations: UseQueryOperations = ({
break;
case ATTRIBUTE_TYPES.HISTOGRAM:
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
break;
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
break;
default:
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
@@ -298,10 +304,17 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.P90,
// A heatmap cell holds a count of observations per `le` band, which is
// the one option the kind offers — a percentile default would sit in the
// selector with nothing behind it.
spaceAggregation:
panelType === PANEL_TYPES.HEATMAP
? MetricAggregateOperator.COUNT
: MetricAggregateOperator.P90,
reduceTo: ReduceOperators.AVG,
},
];
newQuery.bucketOptions = undefined;
} else {
newQuery.aggregations = [
{
@@ -388,6 +401,7 @@ export const useQueryOperations: UseQueryOperations = ({
index,
handleMetricAggregateAtributeTypes,
previousMetricInfo,
panelType,
],
);

View File

@@ -222,6 +222,31 @@ describe('useGetYAxisUnit', () => {
expect(result.current.isError).toBe(false);
});
it('resolves the unit on the first render, without a settling pass', () => {
// The real `useGetMetrics` rebuilds its array on every render; a hook that
// stored the unit would need an extra render to settle, and would schedule one
// after every render of the panel editor.
mockUseGetMetrics.mockImplementation(() => ({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1],
}));
let renderCount = 0;
const { result, rerender } = renderHook(() => {
renderCount += 1;
return useGetYAxisUnit();
});
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(1);
rerender();
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(2);
});
it('should return undefined when metrics have different units', async () => {
mockUseGetMetrics.mockReturnValueOnce({
isLoading: false,

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import {
getMetricUnits,
useGetMetrics,
@@ -46,7 +46,6 @@ function useGetYAxisUnit(
},
): UseGetYAxisUnitResult {
const { stagedQuery } = useQueryBuilder();
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const metricNames: string[] | null = useMemo(() => {
// If the query type is not QUERY_BUILDER, return null
@@ -95,27 +94,13 @@ function useGetYAxisUnit(
[units],
);
useEffect(() => {
// If there are no metrics, set the y-axis unit to undefined
if (units.length === 0) {
setYAxisUnit(undefined);
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
// If all metrics have the same non-empty unit, set the y-axis unit to it
} else if (areAllMetricUnitsSame) {
if (units[0] !== '') {
setYAxisUnit(units[0]);
} else {
setYAxisUnit(undefined);
}
// If there is more than one metric and they have different units, set the y-axis unit to undefined
} else if (units.length > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
} else if (units.length === 1 && units[0] === '') {
setYAxisUnit(undefined);
const yAxisUnit = useMemo(() => {
// A single shared unit is the only thing a single axis can carry; metrics that
// disagree, or that carry no unit at all, leave the axis unitless.
if (units.length === 0 || !areAllMetricUnitsSame) {
return undefined;
}
return units[0] || undefined;
}, [units, areAllMetricUnitsSame]);
return { yAxisUnit, isLoading, isError };

View File

@@ -0,0 +1,78 @@
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type {
IBuilderFormula,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { handleQueryChange } from '../panelQuery';
const bucketOptions = {
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale: 0 },
};
function queryWithFormulaAxis(): Query {
const base = initialQueriesMap[DataSource.METRICS];
const formula: IBuilderFormula = {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
bucketOptions,
};
return {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], bucketOptions }],
queryFormulas: [formula],
},
};
}
describe('handleQueryChange bucket options', () => {
it('keeps both axes when the panel stays a heatmap', () => {
const result = handleQueryChange(
PANEL_TYPES.HEATMAP,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
bucketOptions,
);
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
bucketOptions,
);
});
// The request rejects an axis on any type but heatmap, so a switch has to shed it
// from the query (via the field allowlist) and from the formula (by hand).
it('drops both axes when switching to another panel type', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryData[0].bucketOptions).toBeUndefined();
expect(result.builder.queryFormulas[0].bucketOptions).toBeUndefined();
});
it('leaves the rest of the formula intact', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryFormulas[0]).toMatchObject({
queryName: 'F1',
expression: 'A',
disabled: false,
});
});
});

View File

@@ -8,7 +8,11 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import {
panelTypeDataSourceFormValuesMap,
@@ -95,6 +99,24 @@ export const getIsQueryModified = (
);
};
/**
* Formulas are carried across a panel-type switch whole, not rebuilt from
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
* dropped by hand. The request rejects one on any type but heatmap.
*/
const withHeatmapBucketOptions = (
formulas: IBuilderFormula[],
newPanelType: keyof PartialPanelTypes,
): IBuilderFormula[] => {
if (newPanelType === PANEL_TYPES.HEATMAP) {
return formulas;
}
return (formulas ?? []).map(
({ bucketOptions: _bucketOptions, ...rest }) => rest,
);
};
export function handleQueryChange(
newPanelType: keyof PartialPanelTypes,
supersetQuery: Query,
@@ -138,6 +160,10 @@ export function handleQueryChange(
return tempQuery;
}),
queryFormulas: withHeatmapBucketOptions(
supersetQuery.builder.queryFormulas,
newPanelType,
),
queryTraceOperator:
newPanelType === PANEL_TYPES.LIST
? []

View File

@@ -15,6 +15,7 @@ export type PartialPanelTypes = {
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
export type BuilderField = keyof IBuilderQuery;
@@ -45,6 +46,8 @@ const IS_CARRIED = {
legend: true,
// `dataSource` is appended by the provider; the rest drive surfaces this switch
// does not reach.
// Heatmap only; listed for it below.
bucketOptions: false,
dataSource: false,
temporality: false,
pageSize: false,
@@ -112,6 +115,16 @@ const RAW_ROWS_METRICS: readonly BuilderField[] = omit(
'functions',
);
// `functions` and `having` are dropped rather than carried: the heatmap request
// rejects both. Logs and traces keep the metrics aggregations too, because the map is
// keyed by the query's own signal, which a switch can still be holding.
const HEATMAP: readonly BuilderField[] = omit(
SERIES_METRICS,
'functions',
'having',
);
const HEATMAP_METRICS: readonly BuilderField[] = [...HEATMAP, 'bucketOptions'];
/** Each cell gets its own copy; a shared instance would let cells contaminate
* each other. */
function bySource(
@@ -133,6 +146,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.AREA]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),

View File

@@ -0,0 +1,74 @@
.container {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 12px 8px;
box-sizing: border-box;
}
.label {
flex: 0 0 auto;
font-size: 11px;
line-height: 16px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
.marker {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-1px);
// Reads against the panel through the 3px it overhangs the track at either end,
// which is what carries it where the ramp happens to match it.
background: var(--popover-foreground);
border-radius: 1px;
}
.caption {
flex: 0 0 auto;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.keys {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.key {
display: flex;
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--muted-foreground);
}
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
// strokes in the theme's own direction — light on dark, dark on light.
.hatchSwatch {
width: 11px;
height: 11px;
border-radius: 2px;
border: 1px solid var(--l2-border);
box-sizing: border-box;
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,
var(--muted-foreground) 2px 3px
);
}

View File

@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import Styles from './ColorBar.module.scss';
export interface ColorBarProps {
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
* colours as the cells. */
ramp: string[];
minLabel: string;
maxLabel: string;
/** 0..1. `null` hides the marker. */
markerPosition?: number | null;
/** What the colour encodes, e.g. "count". */
label?: string;
/** Keys the one state a ramp cannot express: a hatched data gap. */
showNoDataKey?: boolean;
'data-testid'?: string;
}
/** What a colour means, plus a marker for the value under the cursor. */
export default function ColorBar({
ramp,
minLabel,
maxLabel,
markerPosition = null,
label,
showNoDataKey = false,
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const gradient = useMemo(() => {
if (ramp.length === 0) {
return undefined;
}
if (ramp.length === 1) {
return ramp[0];
}
const stops = ramp.flatMap((color, index) => {
const from = (index / ramp.length) * 100;
const to = ((index + 1) / ramp.length) * 100;
return [`${color} ${from}%`, `${color} ${to}%`];
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [ramp]);
if (gradient === undefined) {
return null;
}
const clampedMarker =
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
return (
<div className={Styles.container} data-testid={testId}>
{label && <span className={Styles.caption}>{label}</span>}
<span className={Styles.label}>{minLabel}</span>
<div className={Styles.track} style={{ background: gradient }}>
{clampedMarker !== null && (
<span
className={Styles.marker}
style={{ left: `${clampedMarker * 100}%` }}
data-testid={`${testId}-marker`}
/>
)}
</div>
<span className={Styles.label}>{maxLabel}</span>
{showNoDataKey && (
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
<span className={Styles.key}>
<span className={Styles.hatchSwatch} />
no data
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react';
import ColorBar from '../ColorBar';
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
describe('ColorBar', () => {
it('renders the domain labels', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('renders nothing without a ramp', () => {
const { container } = render(
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
);
expect(container).toBeEmptyDOMElement();
});
it('hides the marker when nothing is hovered', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
});
it('positions the marker at the hovered value', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
});
it('clamps a marker outside the ramp to its ends', () => {
const { rerender } = render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
rerender(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
});
it('keys the one state a colour ramp cannot express', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showNoDataKey={true} />,
);
expect(screen.getByText('no data')).toBeInTheDocument();
});
it('leaves the no-data key out unless the data has a gap', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByText('no data')).not.toBeInTheDocument();
});
it('captions what the colour encodes', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
expect(screen.getByText('count')).toBeInTheDocument();
});
it('renders hard-edged segments so the bar matches the drawn cells', () => {
render(
<ColorBar
ramp={['#111111', '#dddddd']}
minLabel="0"
maxLabel="10"
data-testid="scale"
/>,
);
const track = screen.getByTestId('scale').querySelector('div');
expect(track).toHaveStyle({
background:
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
});
});
});

View File

@@ -0,0 +1,31 @@
import cx from 'classnames';
import { formatCount } from './heatmapTooltipContent';
import { HeatmapBucketRow } from './types';
import Styles from './HeatmapTooltip.module.scss';
/** The buckets either side of the hovered one, so a mode reads as a shape rather
* than a single number. */
export default function HeatmapBucketList({
rows,
}: {
rows: HeatmapBucketRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
{rows.map((bucket) => (
<div
key={bucket.row}
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
data-hovered={bucket.isHovered}
data-testid="heatmap-tooltip-bucket-row"
>
<span className={Styles.rowLabel}>{bucket.label}</span>
<span className={Styles.rowSeparator} />
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { formatCount, formatShare } from './heatmapTooltipContent';
import { HeatmapContributionRow } from './types';
import Styles from './HeatmapTooltip.module.scss';
/** Only shown when the cell sums more than one group. */
export default function HeatmapContributionList({
rows,
}: {
rows: HeatmapContributionRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
<div className={Styles.columnHeader}>
<span>Group</span>
<span>Contribution</span>
</div>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="heatmap-tooltip-contribution-row"
>
<span
className={Styles.marker}
style={{ borderColor: row.color, backgroundColor: row.color }}
data-is-legend-marker={true}
/>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowSeparator} style={{ borderColor: row.color }} />
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
<span
className={Styles.rowShare}
data-testid="heatmap-tooltip-contribution-share"
>
{formatShare(row.share)}
</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,200 @@
@use '../../../../../../styles/scrollbar' as *;
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
// as white-on-white in light mode.
//
// Padding lives on the sections rather than here, also matching the shared
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
// corner radius, so it has to reach the container edges.
.container {
// The list pays this less each row's own inset, so its text lands on it too.
--gutter: 14px;
--row-inset: 6px;
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 220px;
&.pinned {
border-color: var(--ring);
}
}
// Separates the cell identity from whichever question the second block answers.
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.identity {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
padding: var(--spacing-8) var(--gutter) 0;
font-size: var(--font-size-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.filter {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
padding: var(--spacing-3) var(--gutter) 0;
}
// Names the group the grid is under, as the legend draws a shown series; it is
// not a colour key.
.filterMarker {
width: 12px;
height: 12px;
border-radius: var(--radius);
border: 1.5px solid currentColor;
background: currentColor;
box-sizing: border-box;
flex-shrink: 0;
}
.filterLabel {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--gutter);
}
.titleBucket,
.titleCount {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: -0.01em;
color: var(--popover-foreground);
}
.titleCount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
var(--spacing-6);
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.columnHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: 0 var(--row-inset) var(--spacing-2);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--row-inset);
border-radius: var(--radius);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
.rowHovered {
background: var(--l3-background);
font-weight: 700;
}
.rowLabel,
.rowValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.rowLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
text-align: right;
white-space: nowrap;
}
.rowShare {
flex: 0 0 auto;
min-width: 34px;
text-align: right;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
color: var(--muted-foreground);
}
.marker {
width: 12px;
height: 12px;
border-radius: var(--radius);
border-style: solid;
border-width: 1.5px;
box-sizing: border-box;
flex-shrink: 0;
}
.rowSeparator,
.titleSeparator {
flex: 1;
border-width: 0.5px;
border-style: dashed;
border-color: currentColor;
min-width: 24px;
opacity: 0.5;
}

View File

@@ -0,0 +1,180 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
resolveColumnIndex,
resolveRowIndex,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { useTimezone } from 'providers/Timezone';
import { HeatmapTooltipProps } from '../../../types';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import HeatmapBucketList from './HeatmapBucketList';
import HeatmapContributionList from './HeatmapContributionList';
import {
buildBucketRows,
buildContributionRows,
formatBucketLabel,
formatColumnRange,
formatCount,
resolveTooltipBody,
} from './heatmapTooltipContent';
import { HeatmapTooltipBody } from './types';
import Styles from './HeatmapTooltip.module.scss';
/**
* The cell identity is the same in every state; the second block answers whichever
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
* rather than composed from the shared `Tooltip`, which renders a flat list of
* series values — none of these states is that shape.
*
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
* the nearest timestamp, so half of every column would report its neighbour.
*/
export default function HeatmapTooltip({
uPlotInstance,
yAxis,
step,
series,
visibleGroups,
groupColor,
yAxisUnit,
decimalPrecision,
timezone,
isPinned,
dismiss,
renderTooltipFooter,
}: HeatmapTooltipProps): JSX.Element | null {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
// Read outside the memo: uPlot mutates the same instance on every move, so
// keying off the instance alone would freeze the cell.
const { left = -10, top = -10 } = uPlotInstance.cursor;
const cell = useMemo(() => {
if (left < 0 || top < 0) {
return null;
}
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
const column = resolveColumnIndex(
timestamps,
uPlotInstance.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
if (column === null || row === null) {
return null;
}
return {
row,
column,
timestamp: timestamps[column],
count:
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
column
] ?? null,
};
}, [left, top, uPlotInstance, yAxis, step]);
// The cell sums the enabled groups, so those are what a breakdown must cover.
const visible = useMemo(
() => series.filter((entry) => visibleGroups.includes(entry.label)),
[series, visibleGroups],
);
const body = resolveTooltipBody(visible.length);
const bucketRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Buckets) {
return [];
}
return buildBucketRows({
counts: uPlotInstance.data.slice(1) as Array<
ArrayLike<number | null> | undefined
>,
yAxis,
row: cell.row,
column: cell.column,
yAxisUnit,
decimalPrecision,
});
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
const contributionRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Contribution) {
return [];
}
return buildContributionRows({
series: visible,
timestamp: cell.timestamp,
row: cell.row,
color: groupColor,
});
}, [cell, body, visible, groupColor]);
if (!cell) {
return null;
}
// A single enabled group out of several means the legend has isolated it.
const isolated =
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
const filterLabel = isolated?.label ?? '';
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="heatmap-tooltip"
>
<div className={Styles.identity}>
<div className={Styles.header}>
<span data-testid="heatmap-tooltip-range">
{formatColumnRange({
start: cell.timestamp,
step,
timezone: resolvedTimezone,
})}
</span>
{isPinned && <TooltipPinnedBadge />}
</div>
{filterLabel && (
<div
className={Styles.filter}
style={{ color: groupColor }}
data-testid="heatmap-tooltip-filter"
>
<span className={Styles.filterMarker} />
<span className={Styles.filterLabel}>{filterLabel}</span>
</div>
)}
<div className={Styles.title}>
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
{formatBucketLabel({
yAxis,
row: cell.row,
yAxisUnit,
decimalPrecision,
})}
</span>
<span className={Styles.titleSeparator} />
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
{formatCount(cell.count)}
</span>
</div>
</div>
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
{body === HeatmapTooltipBody.Contribution ? (
<HeatmapContributionList rows={contributionRows} />
) : (
<HeatmapBucketList rows={bucketRows} />
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -0,0 +1,305 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapAxisScale,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { render, RenderResult, screen } from 'tests/test-utils';
import type uPlot from 'uplot';
import HeatmapTooltip from '../HeatmapTooltip';
const BOUNDS = [100, 500, 1000, 2500];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
const STEP = 300;
const PLOT_SIZE = 500;
const ROW_COUNT = BOUNDS.length + 1;
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
const HOVERED_ROW = 2;
function seriesFor(
group: string,
countsAtHoveredRow: [number, number],
): HeatmapSeries {
return {
label: `{service.name="${group}"}`,
points: TIMESTAMPS.map((timestamp, column) => ({
timestamp,
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
),
})),
};
}
const GROUPED: HeatmapSeries[] = [
seriesFor('checkout', [355, 300]),
seriesFor('frontend', [86, 80]),
seriesFor('cart', [14, 10]),
seriesFor('payments', [0, 0]),
];
/** Grid counts, matching what the renderer would have been handed. */
function gridData(rowTotals: number[]): uPlot.AlignedData {
return [
TIMESTAMPS,
...Array.from({ length: ROW_COUNT }, (_, row) => [
rowTotals[row] ?? row * 40,
rowTotals[row] ?? row * 40,
]),
] as unknown as uPlot.AlignedData;
}
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
const ROW_TOTALS = [10, 269, 455, 92, 2];
function createFakePlot(): uPlot {
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
// Aim the cursor at the middle of the hovered row, first column.
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
return {
data: gridData(ROW_TOTALS),
cursor: { left: PLOT_SIZE * 0.25, top },
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
} as unknown as uPlot;
}
function renderTooltip(
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
): RenderResult {
return render(
<HeatmapTooltip
id="panel-1"
uPlotInstance={createFakePlot()}
dataIndexes={[]}
seriesIndex={null}
isPinned={false}
dismiss={jest.fn()}
viaSync={false}
yAxis={Y_AXIS}
step={STEP}
series={GROUPED}
visibleGroups={GROUPED.map((entry) => entry.label)}
groupColor="#fcfdbf"
yAxisUnit="ms"
{...overrides}
/>,
);
}
describe('HeatmapTooltip — cell identity', () => {
it('heads with the time span the column covers, not a single instant', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
);
});
it('names the hovered bucket and its count', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
'500 ms – 1 s',
);
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
});
it('marks the surface as pinned so the border picks up the ring', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'true',
);
});
it('names a pinned tooltip in its header, as the shared tooltip does', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
});
it('leaves the header unbadged while unpinned', () => {
renderTooltip();
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
});
it('is unpinned by default', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'false',
);
});
it('separates the cell identity from the block below it', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
});
it('renders a footer when the panel supplies one', () => {
renderTooltip({
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
});
it('tells the footer when the tooltip is pinned', () => {
renderTooltip({
isPinned: true,
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
});
it('renders nothing when the cursor is off the plot', () => {
const plot = createFakePlot();
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
const { container } = renderTooltip({ uPlotInstance: plot });
expect(container).toBeEmptyDOMElement();
});
});
describe('HeatmapTooltip — grouped, nothing selected', () => {
it('breaks the cell down by group instead of showing neighbours', () => {
renderTooltip();
expect(
screen.getByTestId('heatmap-tooltip-contribution'),
).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-buckets'),
).not.toBeInTheDocument();
});
it('names each row as the legend does and orders by contribution', () => {
renderTooltip();
const rows = screen
.getAllByTestId('heatmap-tooltip-contribution-row')
.map((row) => row.textContent);
expect(rows[0]).toContain('checkout');
expect(rows[0]).toContain('355');
expect(rows[1]).toContain('frontend');
expect(rows[2]).toContain('cart');
});
it('still lists a group that contributed nothing', () => {
renderTooltip();
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
expect(rows).toHaveLength(GROUPED.length);
expect(rows[3]).toHaveTextContent('payments');
expect(rows[3]).toHaveTextContent('0');
});
it('names what the two columns hold', () => {
renderTooltip();
expect(screen.getByText('Group')).toBeInTheDocument();
expect(screen.getByText('Contribution')).toBeInTheDocument();
});
it('gives each group its share of the cell alongside the count', () => {
renderTooltip();
const shares = screen
.getAllByTestId('heatmap-tooltip-contribution-share')
.map((share) => share.textContent);
expect(shares).toHaveLength(GROUPED.length);
expect(
shares.reduce((sum, share) => sum + parseInt(share ?? '0', 10), 0),
).toBe(100);
expect(shares[shares.length - 1]).toBe('0%');
});
it('does not name a filter when every group is enabled', () => {
renderTooltip();
expect(
screen.queryByTestId('heatmap-tooltip-filter'),
).not.toBeInTheDocument();
});
});
describe('HeatmapTooltip — grouped, one enabled', () => {
const selected = { visibleGroups: ['{service.name="checkout"}'] };
it('returns to neighbouring buckets, since contribution is already answered', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
it('names the active filter', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
'{service.name="checkout"}',
);
});
});
describe('HeatmapTooltip — no grouping', () => {
const ungrouped = {
series: [{ label: '', points: GROUPED[0].points }],
visibleGroups: [''],
};
it('shows neighbouring buckets, highest first', () => {
renderTooltip(ungrouped);
const rows = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.map((row) => row.textContent);
// Two buckets either side of 500ms – 1s, reading down the y axis.
expect(rows).toHaveLength(5);
expect(rows[0]).toContain('> 2.5 s');
expect(rows[2]).toContain('500 ms – 1 s');
expect(rows[4]).toContain('≤ 100 ms');
});
it('marks the hovered bucket among its neighbours', () => {
renderTooltip(ungrouped);
const hovered = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.filter((row) => row.dataset.hovered === 'true');
expect(hovered).toHaveLength(1);
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
});
it('never breaks down a single series', () => {
renderTooltip(ungrouped);
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
const TIMEZONE = 'UTC';
/** 2026-09-02T05:30:00Z. */
const START = 1_788_327_000;
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
const CLOSE_BOUNDS = [
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
0.0681567332915786,
];
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
describe('formatColumnRange', () => {
it('dates the column once while it stays inside a day', () => {
expect(
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 14:30');
});
it('carries the date across a column that spans days', () => {
expect(
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 09/04 05:30');
});
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
expect(
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
).toBe('09/02 05:30:00 → 05:30:30');
});
it('reads the day in the panel timezone, not UTC', () => {
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
// crossing a date boundary there and not in UTC.
expect(
formatColumnRange({
start: START,
step: 9 * 3600,
timezone: 'America/Los_Angeles',
}),
).toBe('09/01 22:30 → 09/02 07:30');
});
});
describe('buildBucketRows', () => {
it('identifies a row by its place on the axis, which its label cannot', () => {
const rows = buildBucketRows({
counts: COUNTS,
yAxis: CLOSE_Y_AXIS,
row: 3,
column: 0,
yAxisUnit: 'ms',
decimalPrecision: 2,
});
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
});
});

View File

@@ -0,0 +1,165 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import 'utils/timeUtils';
import {
HeatmapBucketRow,
HeatmapContributionRow,
HeatmapTooltipBody,
} from './types';
/** Rows shown either side of the hovered one. */
const NEIGHBOUR_SPAN = 2;
/** Below this, the header needs seconds to distinguish columns. */
const SUB_MINUTE_STEP = 60;
export const NO_DATA_LABEL = 'no data';
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
// One enabled group contributes the whole cell, so there is nothing to break
// down — whether the query is ungrouped or the legend has isolated a group.
return visibleCount > 1
? HeatmapTooltipBody.Contribution
: HeatmapTooltipBody.Buckets;
}
/** A cell is an interval, so a single instant would misreport what it contains.
* The start carries the date — the x axis prints one only where the day turns
* over — and the end repeats it only across midnight. */
export function formatColumnRange({
start,
step,
timezone,
}: {
/** Column start, in seconds. */
start: number;
/** Column width, in seconds. */
step: number;
timezone: string;
}): string {
const time =
step < SUB_MINUTE_STEP
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.TIME;
const dated = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
const from = dayjs(start * 1000).tz(timezone);
const to = dayjs((start + step) * 1000).tz(timezone);
const toFormat = to.isSame(from, 'day') ? time : dated;
return `${from.format(dated)} → ${to.format(toFormat)}`;
}
/** Formatted with the panel's unit. */
export function formatBucketLabel({
yAxis,
row,
yAxisUnit,
decimalPrecision,
}: {
yAxis: HeatmapYAxis;
row: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): string {
const bucket = yAxis.rows[row];
if (!bucket) {
return '';
}
return formatRowLabel(bucket, (value) =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
);
}
export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
/** `<1%` rather than `0%`, so a group that did contribute never reads as one
* that didn't. */
export function formatShare(share: number): string {
if (share > 0 && share < 0.005) {
return '<1%';
}
return `${Math.round(share * 100)}%`;
}
/** Highest first, so the list reads in the same direction as the y axis. */
export function buildBucketRows({
counts,
yAxis,
row,
column,
yAxisUnit,
decimalPrecision,
}: {
/** Row-major, as the renderer draws them. */
counts: Array<ArrayLike<number | null> | undefined>;
yAxis: HeatmapYAxis;
row: number;
column: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): HeatmapBucketRow[] {
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const rows: HeatmapBucketRow[] = [];
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
const index = row + offset;
const bucket = yAxis.rows[index];
if (!bucket) {
continue;
}
rows.push({
row: index,
label: formatRowLabel(bucket, formatBucketValue),
count: counts[index]?.[column] ?? null,
isHovered: offset === 0,
});
}
return rows;
}
/**
* Largest first. Groups that contributed nothing are still listed — that is an
* answer, and dropping the row makes the list look truncated.
*/
export function buildContributionRows({
series,
timestamp,
row,
color,
}: {
/** Only the groups the legend has enabled — they are what the cell sums. */
series: HeatmapSeries[];
/** Column start, in seconds. */
timestamp: number;
row: number;
/** The grid's one colour; there is no per-series hue. */
color: string;
}): HeatmapContributionRow[] {
const counted = series.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
return {
label: entry.label,
color,
// Absent or null contributed nothing to the sum this breaks down.
count: point?.counts[row] ?? 0,
};
});
const total = counted.reduce((sum, entry) => sum + entry.count, 0);
return counted
.map((entry) => ({
...entry,
share: total > 0 ? entry.count / total : 0,
}))
.sort((a, b) => b.count - a.count);
}

View File

@@ -0,0 +1,24 @@
/** Which question the second block answers: "which group?" for a cell summed
* across several, "how does this bucket compare?" for one that is one series. */
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
* round to the same text — so this is what identifies a row. */
row: number;
label: string;
/** `null` where the bucket has no observation in that column, never a 0. */
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Fraction of the cell this group contributed, 0..1. */
share: number;
}

View File

@@ -16,15 +16,3 @@
.pinnedItem {
padding: var(--spacing-4);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -1,13 +1,13 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { Pin } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import Styles from './TooltipHeader.module.scss';
@@ -67,14 +67,7 @@ export default function TooltipHeader({
{showTooltipHeader && headerTitle && (
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{headerTitle}</span>
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
<>
<Pin size={12} />
<span>Pinned</span>
</>
</div>
)}
{isPinned && <TooltipPinnedBadge />}
</div>
)}

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