Compare commits

...

43 Commits

Author SHA1 Message Date
Gaurav Tewari
56cef582e4 refactor: trace details and and header changes 2026-10-07 16:47:35 +05:30
Gaurav Tewari
67b9f42e7a feat: trace detail header & tooltip changes 2026-10-07 16:47:35 +05:30
Gaurav Tewari
8537a7688c feat: update badge color 2026-10-07 16:47:34 +05:30
Gaurav Tewari
6e8e6f3659 feat: add support for badge and update ai icons 2026-10-07 16:47:34 +05:30
Gaurav Tewari
b317048220 chore: update tooltip design and support ai spans 2026-10-07 16:47:34 +05:30
Gaurav Tewari
5c9a8eb19f chore: another refactor approch 2026-10-07 11:17:33 +00:00
Gaurav Tewari
a54206ed5e feat: add trace tab and initial refactor 2026-10-07 11:17:33 +00:00
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
Srikanth Chekuri
1643df620b feat: resolve semconv families across logs and metrics (#12870)
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

Phase 2 of #6143: semantic-convention families resolve on logs and
metrics, behind the `resolve_semconv_families` flag (default off), on
the storage contract of #12802.

- Registry: each member carries the scope of its own rename edges, so a
fan-out keeps one membership per target and an ambiguous name stays
literal.
- Logs and metrics families need no family code of their own. The gate
applies to every signal, and `LogicalRead` merges members through each
storage's `Read`.
- Metric-name families union the storage names in every `metric_name`
filter, and the querier reads type, temporality, and the reduced flag
across the family.
- Span-metrics labels: the metrics the processor emits, listed by name,
also read each family member with the `resource_` prefix. Requested
names are never rewritten.
- Values suggestions and related values cover every spelling of the
family.
- `deployment.environment.name` resolves on all three signals.
`db.system.name` stays off until a value-mapping reader exists.

#### Additional Information

- `pkg/semconv.Family` fields are now unexported, and `transition.go` is
removed. #12446 reads the old API and needs an update when stacked.
- A target that emits both names of a metric-name family double-counts
in `sum()` during the overlap window. Reading both names is the feature.
Pinned by a test.
- A family of metrics labels keeps the keyless contract of a single
label: no guard, no NULL group.
2026-10-01 20:28:15 +00:00
Aditya Singh
fd21f8b760 feat(bottom-strip): show the page count on the left for noz, alerts and home (#12963)
#### Description

- noz page, alert rules, home, exceptions and services now push their
count to the left of the strip. each page has its own `useXStripInfo`
hook that builds the config and publishes it.. same pattern trace
details already uses, five more times.
- the hook does the whole thing, so the page call is one line. noz and
home fetch or subscribe inside the hook so the page does not re-render
just to keep the strip current.. the rest pass values they already hold.
- alert rules and exceptions show "N of M".. first number is what is on
the page right now. both read the same two values the table hands its
own pagination, so the strip cannot disagree with the table. services,
home and noz are a plain count.
- services renders one of two tables on `use_span_metrics`, so the hook
is called from both.. the count text lives in one place either way.
- dashboards is left out for now, it already shows this on its own
strip.

#### Issues closed by this PR

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


#### Screenshots

Ai Assistant  Bottom strip

<img width="3456" height="1968" alt="image"
src="https://github.com/user-attachments/assets/0042f71b-1b56-417a-8283-af9ba9596351"
/>


Alert rules

<img width="3452" height="1992" alt="image"
src="https://github.com/user-attachments/assets/a674de13-9841-4717-89ce-a656c9df646c"
/>

Exceptions 

<img width="3456" height="1970" alt="image"
src="https://github.com/user-attachments/assets/6333ea6b-2342-46dc-985d-2855bebe4003"
/>



Services

<img width="3456" height="1996" alt="image"
src="https://github.com/user-attachments/assets/abe0c5cb-cfe3-48b6-9591-ee71e677a1a6"
/>




#### Additional Information
2026-10-01 19:30:00 +00:00
Vinicius Lourenço
434b282549 chore(dashboard-story): answer variable endpoints outside the Data state control (#13000)
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

To try prevent flakyness of
https://github.com/SigNoz/signoz/actions/runs/36460317287/job/109056950899#step:7:9349
2026-10-01 14:07:41 +00:00
Aditya Singh
ecdc57836c feat(bottom-strip): add right side actions and the per page left slot (#12961)
#### Description

- adds ask noz and support to the right side of the strip. support is
one button covering both floating bubbles.. only one of them ever showed
at a time, so it is pylon for users who have it and the add credit card
modal for trial users without a card. only the bubbles are hidden, noz
in the header and side nav stays for now.
- the support gating was spread across AppRoutes and two components,
each rebuilding the same feature flag + license + trial checks. pulled
into `useChatSupport`, and the add credit card modal into one shared
component.. support page still has its own copy, that goes with cleanup.
- left side is a store the strip subscribes to. pages push a node with
`useBottomStripLeft` and it clears on unmount.. strip knows nothing
about pages. falls back to the build version when nothing is set.
- the node comes from the page, so it is wrapped in an error boundary
that falls back to the version. the strip sits outside the app layout
boundary on purpose so it survives a page crash.. without this a bad
node would reach the top level one and blank the whole app.
- trace details is the first consumer, spans and errors. page passes the
values instead of the node fetching them.. the trace query key includes
the selected span so a self fetching node would refetch on every span
click. header counts stay as is.

#### Issues closed by this PR

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

#### Screen recording


https://github.com/user-attachments/assets/12d476e5-a486-4903-974e-e0964ffa3e3c



#### Additional Information

- no loading state or error handling on the count yet and the numbers
are raw.. doing all three in one pass once the rest of the pages are
wired.
- no analytics here, that comes with the analytics ticket.
- pylon and the add card flow were tested locally with temp code. the
pylon chat window offset and bubble hiding still need a pylon enabled
tenant to verify.
- the conversation history popover for the right side is still to do, so
6075 stays open.
2026-10-01 13:39:35 +00:00
Vikrant Gupta
572345be63 feat(authz): enable FGA for users and reset password tokens (#13020)
#### Description

- Moves the users API off the legacy `AdminAccess` gate onto
`CheckResources` + `ResourceDef`s; `me` and anonymous password flows
stay `OpenAccess`.
- Invite checks `role:attach` per requested role; an empty role list
resolves to no link, so the sibling def skips the check.
- Migration `131_add_user_tuples` backfills admin `user` and
`factor-password` tuples for existing orgs.

#### Issues closed by this PR

Closes: SigNoz/keystone-pod#38
2026-10-01 11:26:18 +00:00
Nikhil Soni
bcf96f2816 feat(traces-qb): gate JSON span attribute reads behind a feature flag (#12966)
#### Description

- The new feature flag `use_trace_attributes_json` (default off) now
gates the JSON columns in the traces `getColumn`, alongside the
evolution entry.
- `SelectEvolutionsForColumns` now ignores evolutions of columns the
mapper didn't return instead of erroring, so a flag-off attribute
resolves to its map even though the key carries the JSON evolution.

Part of https://github.com/SigNoz/signoz/pull/12966

#### Additional Information

- Evolution entry migration: SigNoz/signoz-otel-collector#928
- Original QB PR: #4781
2026-10-01 10:21:10 +00:00
Yunus M
a707ed6928 feat: implement error handling and retry mechanism for assistant messages (#11913)
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
## Pull Request

---

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

The AI Assistant previously surfaced streaming failures with minimal
structure: a plain error string, no distinction between transient vs
permanent failures, and no way for users to recover without retyping or
refreshing. Backend SSE errors now carry `retryAction` (`auto` /
`manual` / `none`) and structured error codes, but the frontend was not
honoring that contract end-to-end.

This PR wires full error handling and retry into the assistant:

- **Centralized error resolution** — `resolveAssistantErrorMessage` is
replaced by `resolveAssistantError`, which maps backend codes to
user-friendly copy, classifies rate-limit vs non-retryable errors, and
derives the correct `retryAction`.
- **Dual retry budgets in the store** — `streamWithAuthRetry` becomes
`streamWithRetry`, handling auth expiry (one silent re-attempt) and
backend-flagged transient errors (up to 2 auto-retries with 500ms /
1500ms backoff). When auto-retries are exhausted, the error is
downgraded to `manual` so the user can still retry.
- **Manual retry UX** — failed turns commit as styled error bubbles
(`isError`, `errorCode`, `retryAction`). Manual errors show an inline
**Retry** button that replays the originating action (send, approve,
clarify, regenerate) without duplicating the user message. A transient
`retryRegistry` holds the replay thunk for the latest failed turn.
- **Analytics** — `RetryClicked` event fired when the user clicks Retry.

This aligns the UI with the backend error contract and gives users a
clear, actionable path to recover from transient failures.

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

| Before | After |
|--------|-------|
| Generic/unstructured error text in assistant bubble | Error callout
with warning icon, code-specific copy, and **Retry** button for manual
errors |
| No retry affordance | Retry replays the failed action; auto-retries
happen silently for transient errors |
| Feedback bar shown on errors | Feedback/regenerate hidden on error
bubbles; rate-limit errors still suppress retry |

_Add screenshots of: (1) `thread_busy` manual error with Retry, (2)
rate-limit error with no Retry, (3) successful recovery after Retry._

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

Fixes:  https://github.com/SigNoz/nerve-pod/issues/92

---

### ✅ Change Type
_Select all that apply_

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

---

### 🐛 Bug Context
> Required if this PR fixes a bug

N/A — this is primarily a feature/enhancement to error handling UX, not
a targeted bug fix.

---

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

- Tests added/updated:
- `resolveAssistantError.test.ts` — error code copy, rate-limit
classification, non-retryable codes, HTTP/SSE error shapes,
`retryAction` derivation
- `useAIAssistantStore.test.ts` — manual error bubbles, manual retry
replay (send + approve), auto-retry with backoff and downgrade to
manual, silent recovery on auto-retry success, rate-limit errors with no
retry
- `MessageBubble.test.tsx` — error styling, Retry button visibility
(`manual` vs `none`), `onRetry` callback, feedback bar suppressed on
errors
- Removed `resolveAssistantErrorMessage.test.ts` (superseded by
`resolveAssistantError`)

- Manual verification:
- Trigger `thread_busy` during an active execution → error bubble with
Retry → click Retry → successful response without duplicate user message
- Trigger rate-limit error → error bubble, no Retry button, no feedback
bar
- Trigger transient `internal_error` with `retryAction: auto` → silent
retries; if all fail, manual Retry appears

- Edge cases covered:
- Auth expiry mid-stream (`invalid_token`) — one auth retry via
`streamWithRetry`
  - Auto-retry budget exhaustion (2 attempts) → manual Retry affordance
- Retry replays originating action for approve/clarify/regenerate, not
just send
  - Retry no-op when `retryAction: none` or no registry entry
  - Retry disabled while streaming is active

---

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

- Blast radius: AI Assistant chat only — `useAIAssistantStore`,
`MessageBubble`, `VirtualizedMessages`, error utils
- Potential regressions:
- Error copy regressions if a new backend code is not in
`ERROR_CODE_COPY` (falls back to backend message)
- Auto-retry backoff may add latency (up to ~2s) before surfacing a
manual error for transient failures
- `retryRegistry` is in-memory only — page reload drops retry capability
(acceptable; user can resend)
- Rollback plan: Revert PR; no schema/migration changes

---

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

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature |
| Description | AI Assistant now shows clearer error messages with
inline Retry for recoverable failures, and silently retries transient
backend errors before asking the user to retry. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [x] Backward compatibility considered

---

## 👀 Notes for Reviewers

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

- **`resolveAssistantErrorMessage.ts` → `resolveAssistantError.ts`**:
The new util returns a full `AssistantErrorResolution` object instead of
just a string. All call sites in the store were updated accordingly.
- **`streamWithRetry`**: Auth retry (1×) and auto retry (2× with
backoff) are independent budgets. When auto retries are spent,
`retryAction` is forced to `manual` before the error propagates to
`finalizeStreamingError`.
- **`retryRegistry`**: Transient map keyed by `conversationId`; not
persisted. Pairs with the error bubble's lifetime.
- **Error bubble UI**: Uses `@signozhq/ui` `Button` and
`@signozhq/icons` (`TriangleAlert`, `RotateCw`). Feedback/regenerate bar
is hidden on `isError` messages (same as rate-limit).
- **Files touched (12)**: store, error util, MessageBubble (+ styles),
VirtualizedMessages, types, events, 3 new test files, 1 removed test
file.

---
2026-10-01 05:46:02 +00:00
Vikrant Gupta
e374d03e54 revert(authz): restore gjson-based body extraction in resource middleware (#13024)
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

- Reverts #13014 and #13015. The resource middleware goes back to
reading body-derived resource ids with `BodyJSONPath` / `BodyJSONArray`
over the raw body, and handlers decode their own request bodies again.
- Authz should not own request decoding; that ownership stays with the
handlers.

#### Additional Information

- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-30 13:53:21 +00:00
Swapnil Nakade
d445b6c296 chore: bumping cloud integration agent version to v0.0.15 (#13023)
<!--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
Bumping the cloud integration agent's version to latest v0.0.15

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/101
2026-09-30 12:38:24 +00:00
Swapnil Nakade
fd8aaac300 feat: adding sync state in cloud integration (#12991)
<!--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
The agent only saw the current list of enabled regions, so it couldn’t
tell which regions had been removed. To find stacks to clean up, it
checked unrelated AWS regions, causing unnecessary calls and permission
errors. Sync state keeps track of regions sent to the agent and pending
removals until the agent acknowledges cleanup.

Please check
[comment](https://github.com/SigNoz/keystone-pod/issues/101#issuecomment-5832865898)
for approach

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

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
This PR should be merged before changes for cloud-integration repo.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-30 11:39:46 +00:00
Naman Verma
e4cd8dbf10 chore: store v2 config for notification channels in db (#12984)
<!--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

1. add a config column to notification channels table where the channel
config goes without dealing with receiver at all. this helps in cleaning
up all round trip issues caused by dealing with receiver in the storage
layer. v2 apis treat receiver as a side effect now
1a. for applicable fields, defaults are filled in create/update api if
fields are omitted
1b. explicit [] and {} are no longer dropped, and omitted [] and {} are
returned as explicit null
2. add integration tests for all notification channel round trip issues
3. code cleanup of v2 channels types 
4. migration to fill the config column from receiver column, which logs
results like dashboards migration did
4a. it fails for receivers that cannot be modeled in v2. repair api can
be used for them
4b. for types that v2 supports, any fields that v1 supports but v2
doesn't, this migration drops those fields
5. make v1 API reject anything that v2 apis do not support, and also
fill the new config column added
6. add integration tests for v1<>v2 interaction to ensure that alert
manager doesn't break because of new changes added

What breaks/changes for v1:
1. types that v2 does not support can no longer be created
2. channels with multiple receivers cannot be created
3. channels with fields that v2 does not support cannot be created
4. existing channels of types that v2 does not support can no longer be
edited via v1 API. They can be deleted though. Also, they keep on
sending notifications as before (sigh).

<!--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/376
Closes https://github.com/SigNoz/pulse-pod/issues/378
2026-09-30 11:30:33 +00:00
praneeth-signoz
81d024dfc8 chore(channel-receivers): relax channel validations (#13008)
<!--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

- Relax all the strict validations like https, host name...for all the
channels. And why this is needed ?

1. Channel URLs were pinned to the vendor's own host —
`chat.googleapis.com`, `*.atlassian.net`— which blocked deployments that
send notifications through a proxy or relay.
2. Provider's contract is not ours to hardcode — so we check the field
is there and let the provider reject what it doesn't accept

<!--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/374
2026-09-30 10:20:41 +00:00
Vikrant Gupta
8dba9a13ea fix(authz): read every body-derived resource id from the decoded request (#13015)
#### Description

- Follows #13014. Moves the remaining body-derived resource ids (gateway
limits, zeus hosts, cloud integration check-ins, auth domains, query
range) off gjson and onto the decoded request, with the handlers reading
the same value. Part of SigNoz/keystone-pod#37.
- Removes `BodyJSONPath`, `BodyJSONArray`, and
`ExtractorContext.RequestBody`.

#### Issues closed by this PR 

- Closes: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-30 09:34:32 +00:00
Vikrant Gupta
8d80f98710 fix(authz): decode the request body once in the resource middleware (#13014)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
#### Description

- The resource middleware now decodes the body once into the route's
declared `OpenAPIDef.Request` type, rejects a malformed body before any
check, and carries the decoded value on `ExtractorContext.Body`.
`BodyField` / `BodyFields` read ids off that value, and handlers read
the same value via `coretypes.BodyFromContext`.
- `handler.Handler` exposes `Request()`, and `handler.New` panics when a
route with resource defs declares a non-pointer request, since the
middleware instantiates it.
- Only `POST /api/v1/service_account_roles` is wired to the new
extractors in this PR to keep the review small. The remaining body
routes still use the gjson extractors and decode again in their
handlers.

#### Issues closed by this PR
- Contributes to: https://github.com/SigNoz/keystone-pod/issues/37
2026-09-29 21:32:25 +00:00
Aditya Singh
adfcebf855 feat(explorer): create alert and add to dashboard actions in the explorer views (#12981)
#### Description
- Create alert and Add to dashboard now live in the view that owns the
row.. logs list row, logs / traces time series and table headers, traces
list and trace rows, metrics per chart. Same line as download
everywhere. First slice of pulling the actions out of the bottom bar,
the bar keeps its own two till it goes so they show twice for now.
- new `ExplorerActions` renders the pair.. the view hands over its
export query, same query the bar gets so nothing changes in what reaches
the alert / dashboard. `TimeSeriesView` got a `headerActions` slot for
it.
- metrics one chart per query: each chart carries its own alert /
dashboard / download for that chart's query, icon only in the split
layout. no per query picker needed.
- alert shaping is source agnostic now.. every noop becomes count and
list / trace panels drop `orderBy` whatever the source. bar only checked
the first query and only stripped for logs. neutral today, traces
already sends `orderBy: []` for those views.
- `DownloadOptionsMenu` moved to the design system buttons.. list
download was the antd primary tinted icon, did not match the rest of the
row.
- llm explorer and meter untouched.. llm gets it later, meter never had
these buttons in the bar.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/56




https://github.com/user-attachments/assets/a3b7f08c-2c62-4243-8cd7-4dd25cb315fd



#### Additional Information
- no flag.. buttons are live from merge, the bar stays till saved views
lands in the sidebar.
- logs controls row is list only now.. everything in it was list gated
once the buttons moved into the view headers, it was an empty strip on
time series / table.
- analytics is one generic event per action with `sourcepage` in the
payload, not the per explorer names the bar used.
- verified bar == button (alert url and dashboard link) in the browser
for all logs / traces views and metrics single, split and unsplit. tests
pin the query pushed to the url per view.
2026-09-29 16:43:05 +00:00
Swapnil Nakade
254758942e fix: using allLogs categoryGroups for azure container apps (#13013)
#### Description

- The Container Apps logs pipeline failed because the definition passed
log category names (`ContainerAppConsoleLogs`, `ContainerAppSystemLogs`)
as `categoryGroups`. Azure accepts only `allLogs` or `audit` there, so
it rejected the diagnostic setting.
- Switched to `allLogs`, matching the other Azure services. The agent
picks this up on its next config sync, so no agent release is needed.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#45

#### Additional Information
- Not tested on live Azure. Microsoft's built-in policy for
`Microsoft.App/managedEnvironments` sends the same `allLogs` setting to
Event Hub.
2026-09-29 14:24:00 +00:00
Aditya Singh
8a5d98f467 chore(logs): remove the old log details drawer (#12938)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- `useIsLogDetailsV2` was a route test, not a feature flag.. v2 rendered
on the logs explorer, infra monitoring and dashboards and everything
else fell back to v1. made v2 the only drawer, so the pipelines preview
gets it too. that's the one behaviour change here.
- deleted the v1 code that leaves behind.. the attribute table and its
json processing, the two HOCs, the `ActionItem` component and the
separate JSON tab. `Overview` is down to its DataViewer path, which also
drops monaco from the logs bundle. `DataType` and the two props types
move out first since MetricsExplorer and infra monitoring read them.
- dropped the standalone `/logs/logs-explorer/live` route. nothing
navigated to it, the time picker's Live option is in-page state on the
explorer. the components stay, that in-page mode still renders them.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5933

#### Additional Information

- `onAddToQuery` stays for now. it is v1-only inside the drawer but five
call sites still thread it through, a couple of them doubling it as
`onClickActionItem`.. untangling that is a refactor rather than a
deletion.
- `useLogAttributeActions` gated group-by and replace-filter on "old
explorer or live logs". with both routes gone the guard is always false,
so it collapses.
2026-09-29 13:30:34 +00:00
praneeth-signoz
47dd1fabf3 chore(channel-specs): Move channel specs to separate files (#12989)
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

- Moved channel specs to separate files under alert manager types
- Channel receivers are also moved the same file

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes
https://github.com/orgs/SigNoz/projects/34/views/26?pane=issue&itemId=252814150&issue=SigNoz%7Cpulse-pod%7C374
2026-09-28 17:44:35 +00:00
Vikrant Gupta
270988fb48 fix(tokenizer): persist last_observed_at on postgres (#12982)
#### Description

- The flush CTE rendered `last_observed_at` as an untyped literal, which
postgres resolves to `text` and refuses to assign to the `timestamptz`
column. The column never populated, so the idle expiry never applied.
- Build the CTE from the token model with only `id`, `last_observed_at`
and `updated_at`, so bun casts per dialect and no token secrets land in
the statement.
- Flush now applies cached times through `Token.UpdateLastObservedAt`,
which also skips rows with a newer stored value.
- Integration test in `passwordauthn` runs with a short GC interval and
asserts the column populates on both sql stores.
2026-09-28 14:23:01 +00:00
Naman Verma
39badeb591 fix: remove rules types package import from migration #049 (#12998)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (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

Migration package ideally should have have things from types package
imported. Given that rules v1->v2 will (most probably) update/remove
some of the types, such as removing `PreferredChannels` from
`PostableRule`, better not to have this type imported in migrations
package.

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

Part of https://github.com/SigNoz/pulse-pod/issues/225
2026-09-28 11:39:07 +00:00
Nityananda Gohain
ec05bfe755 fix: empty patterns in pricing are rejected (#12995)
<!--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
Empty patterns were not rejected because of which corrupt config was
created, rejecting them at the handler layer.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/282
2026-09-28 08:09:38 +00:00
Aditya Singh
ed1bf7ab89 fix(bottom-strip): size pages from the layout instead of the viewport (#12940)
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

- pages that hardcoded `100vh` minus a guess at what sits above them
came out taller
than the pane they live in, which showed up as scroll that should not be
there. they
  now take what the layout gives them.
- most of the `100vh` in the app turned out to be harmless.. either
flex-shrink absorbs
it or the pane scrolls anyway. those are left alone, only the ones with
a real symptom
  are changed here.
- alert rules and triggered alerts also needed the AlertList tabs chain
to hand height
  down, that page uses antd `Tabs` directly instead of `RouteTab`.
- licenses, status and support pages had `max-height: 100vh` with
`overflow: hidden` and
no inner scroller, so anything past a viewport was clipped with no way
to reach it.
  removed the cap on all three.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6074


#### Screenshots/ Screen recording

Home page


https://github.com/user-attachments/assets/8ba3e3c9-1959-4393-b503-ddf579e9d139

Without bottom strip


https://github.com/user-attachments/assets/2ee08181-710a-4c34-a4e7-893c4a320453



Status page

<img width="1728" height="1000" alt="status"
src="https://github.com/user-attachments/assets/f44ec7d7-4ee0-4866-9a90-fe25622fe25b"
/>

Without bottom strip

<img width="1728" height="997" alt="status2"
src="https://github.com/user-attachments/assets/be57dce6-65ca-4450-8f0d-0337625a1d64"
/>

Alert rules


https://github.com/user-attachments/assets/7cc25f56-54e4-4489-bdb4-453409151bac

Triggered alerts


https://github.com/user-attachments/assets/2d8e1885-7cad-4659-90f6-59960f4afce8

Without bottom strip



https://github.com/user-attachments/assets/5247f6c0-3675-4910-b1e4-4076bf93c16e



Support
<img width="1728" height="997" alt="support"
src="https://github.com/user-attachments/assets/c2b3c0b2-2abd-4538-bb52-2660e75817b3"
/>

Trace funnel



https://github.com/user-attachments/assets/c4872135-3cb5-4348-b6c6-2d3e4dabdc1b

Without bottom strip


https://github.com/user-attachments/assets/aef94d5d-02e5-4154-995b-76819d55c2d8


Trace details



https://github.com/user-attachments/assets/24d701b0-296f-4fb4-ba9d-615a47b33285

Without bottom strip


https://github.com/user-attachments/assets/23083f20-67bf-4b06-aaec-aba390a6f594



#### Additional Information

- every page here was checked on screen before and after. the ones left
untouched
(infra hosts/k8s, traces + llm explorer list views, llm settings tables,
the k8s logs
  drawer) were checked too and are fine.
2026-09-25 15:14:44 +00:00
Aditya Singh
6e979c8318 feat(bottom-strip): add the layout shell behind a feature flag (#12936)
#### Description

- adds the bottom strip to the app layout behind a localStorage flag.
shows the build
version on the left for now.. right side actions and the per page count
come in the
  next tickets.
- `.app-content` is a column flex now and `LayoutContent` takes the
height left over
instead of `height: 100%`, so the strip has a stable box to sit under.
this is the
  only bit not behind the flag.
- fixed bottom elements read `--bottom-strip-height`. the var only
exists while the
strip is mounted, so with the flag off everything falls back to where it
is today.
- hides nothing. each later ticket hides the piece it replaces.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6074

<img width="3084" height="1566" alt="image"
src="https://github.com/user-attachments/assets/b1821fda-5c33-40e7-926a-5d91fedb797e"
/>


#### Additional Information

- pages that still hardcode `100vh` (infra hosts/k8s, trace details,
traces and llm
list views) push the strip off screen. that is the next PR on this
ticket.
- pylon chat window offset is not here.. needs a pylon enabled tenant to
verify so it
  goes with the right side actions ticket.
2026-09-25 12:04:21 +00:00
praneeth-signoz
3b6becff7a chore(tests): split alerts test suite as per domain boundaries (#12983)
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
Segregated alert-related test suite in to alert manager and ruler as per
their domain boundaries.

---------

Co-authored-by: Praneeth Lingam <praneethlingam@Ollys-MacBook-Pro.local>
2026-09-25 07:30:53 +00:00
571 changed files with 30552 additions and 9341 deletions

View File

@@ -67,7 +67,7 @@ jobs:
with:
go-version: "1.24"
- name: check-semconv-generated-files
run: go run ./scripts/semconv -check
run: make semconv-check
build:
if: |
github.event_name == 'merge_group' ||

View File

@@ -38,7 +38,6 @@ jobs:
fail-fast: false
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
@@ -64,10 +63,12 @@ jobs:
- querierauthz
- role
- rootuser
- ruler
- savedview
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -262,6 +262,10 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
@go run ./scripts/semconv
.PHONY: semconv-check
semconv-check: ## Fail if the generated semantic-convention files are stale
@go run ./scripts/semconv -check
.PHONY: gen-mocks
gen-mocks:
@echo ">> Generating mocks"

View File

@@ -184,6 +184,7 @@ components:
headers:
additionalProperties:
type: string
nullable: true
type: object
html:
type: string
@@ -217,6 +218,7 @@ components:
metadata:
additionalProperties:
type: string
nullable: true
type: object
sendResolved:
nullable: true
@@ -258,6 +260,7 @@ components:
type: string
customFields:
additionalProperties: {}
nullable: true
type: object
description:
type: string
@@ -268,6 +271,7 @@ components:
labels:
items:
type: string
nullable: true
type: array
priority:
type: string
@@ -346,6 +350,7 @@ components:
details:
additionalProperties:
type: string
nullable: true
type: object
message:
type: string
@@ -374,6 +379,7 @@ components:
details:
additionalProperties:
type: string
nullable: true
type: object
group:
type: string
@@ -451,6 +457,7 @@ components:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
nullable: true
type: array
apiUrl:
format: password
@@ -464,6 +471,7 @@ components:
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
nullable: true
type: array
footer:
type: string
@@ -1763,12 +1771,15 @@ components:
additionalProperties: {}
nullable: true
type: object
syncState:
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
timestampMillis:
format: int64
type: integer
required:
- timestampMillis
- data
- syncState
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
@@ -2013,6 +2024,8 @@ components:
format: date-time
nullable: true
type: string
syncState:
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
required:
- account_id
- cloud_account_id
@@ -2022,6 +2035,7 @@ components:
- providerAccountId
- integrationConfig
- removedAt
- syncState
type: object
CloudintegrationtypesGettableServicesMetadata:
properties:
@@ -2121,6 +2135,9 @@ components:
type: object
providerAccountId:
type: string
syncedVersion:
nullable: true
type: integer
required:
- data
type: object
@@ -2133,6 +2150,18 @@ components:
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
type: object
CloudintegrationtypesRegionState:
enum:
- enabled
- disabled
type: string
CloudintegrationtypesRegionSyncState:
properties:
state:
$ref: '#/components/schemas/CloudintegrationtypesRegionState'
required:
- state
type: object
CloudintegrationtypesService:
properties:
assets:
@@ -2274,6 +2303,23 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesSyncState:
nullable: true
properties:
inSync:
type: boolean
regions:
additionalProperties:
$ref: '#/components/schemas/CloudintegrationtypesRegionSyncState'
type: object
version:
format: int64
type: integer
required:
- version
- inSync
- regions
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -3685,6 +3731,84 @@ components:
hide:
type: boolean
type: object
DashboardtypesHeatmapAxes:
properties:
"y":
$ref: '#/components/schemas/DashboardtypesHeatmapAxis'
type: object
DashboardtypesHeatmapAxis:
properties:
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -4038,6 +4162,7 @@ components:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -4056,6 +4181,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4068,6 +4194,7 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4093,6 +4220,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
@@ -8995,6 +9134,15 @@ components:
- alertType
- ruleType
type: object
RuletypesListableRuleViews:
properties:
views:
items:
$ref: '#/components/schemas/RuletypesRuleView'
type: array
required:
- views
type: object
RuletypesListableRules:
properties:
labels:
@@ -9092,6 +9240,16 @@ components:
- ruleType
- condition
type: object
RuletypesPostableRuleView:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
name:
type: string
required:
- name
- data
type: object
RuletypesQueryType:
enum:
- builder
@@ -9230,6 +9388,45 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesRuleView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
RuletypesRuleViewData:
properties:
order:
$ref: '#/components/schemas/RuletypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/RuletypesListSort'
states:
items:
type: string
type: array
version:
type: string
required:
- version
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -9647,6 +9844,40 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceSummary:
properties:
ai:
$ref: '#/components/schemas/SpantypesTraceAISummary'
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
rootSpanStatusCode:
type: string
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
required:
- startTimestampMillis
- endTimestampMillis
- rootServiceName
- rootServiceEntryPoint
- rootSpanStatusCode
- totalSpansCount
- totalErrorSpansCount
- hasMissingSpans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -9961,6 +10192,39 @@ components:
nullable: true
type: object
type: object
SpantypesTraceAISummary:
properties:
tokens:
$ref: '#/components/schemas/SpantypesTraceAITokens'
totalCost:
type: number
required:
- tokens
type: object
SpantypesTraceAITokens:
properties:
cacheRead:
minimum: 0
type: integer
cacheWrite:
minimum: 0
type: integer
input:
minimum: 0
type: integer
output:
minimum: 0
type: integer
reasoning:
minimum: 0
type: integer
required:
- input
- output
- cacheRead
- cacheWrite
- reasoning
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -15685,6 +15949,66 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/summary:
get:
deprecated: false
description: 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.
operationId: GetTraceSummary
parameters:
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get summary for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true
@@ -21140,13 +21464,242 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- role:read
- tokenizer:
- ADMIN
- role:read
summary: Get users by role id
tags:
- users
x-signoz-stability: alpha
/api/v2/rule_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListRuleViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRuleViews'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List rule saved views
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: Persists the calling user's rule listing state (query, states,
sort, order) as a named, reusable view shared across the org.
operationId: CreateRuleView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Create rule saved view
tags:
- rules
x-signoz-stability: alpha
/api/v2/rule_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Delete rule saved view
tags:
- rules
x-signoz-stability: alpha
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Update rule saved view
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true
@@ -25728,9 +26281,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:attach
- role:attach
summary: Create user role
tags:
- users
@@ -25781,9 +26336,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:detach
- role:detach
- tokenizer:
- ADMIN
- user:detach
- role:detach
summary: Delete user role
tags:
- users
@@ -25845,9 +26402,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user role
tags:
- users
@@ -25894,9 +26451,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:list
- tokenizer:
- ADMIN
- user:list
summary: List users v2
tags:
- users
@@ -25957,9 +26514,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:create
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:create
- user:attach
- role:attach
summary: Create user
tags:
- users
@@ -26004,9 +26565,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:delete
- tokenizer:
- ADMIN
- user:delete
summary: Delete user
tags:
- users
@@ -26062,9 +26623,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user by user id
tags:
- users
@@ -26119,9 +26680,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:update
- tokenizer:
- ADMIN
- user:update
summary: Update user v2
tags:
- users
@@ -26178,9 +26739,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:list
- tokenizer:
- ADMIN
- factor-password:list
summary: Get reset password token for a user
tags:
- users
@@ -26244,9 +26805,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:create
- user:attach
- tokenizer:
- ADMIN
- factor-password:create
- user:attach
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -26305,9 +26868,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user roles
tags:
- users
@@ -26621,10 +27184,7 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
- tokenizer: []
summary: Updates my password
tags:
- users

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
### The generic layer
@@ -109,7 +109,7 @@ The functions, from the outside in:
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
### A resolved key

View File

@@ -183,32 +183,52 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
}
account, err := cloudintegrationtypes.NewAccountFromStorable(storableAccount)
if err != nil {
return nil, err
}
syncState := account.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
// The agent uses this response to clean up resources
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if account.AgentReport != nil && syncState != nil {
account.UpdateSyncState(syncState)
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
}
// update account with cloud provider account id and agent report (heartbeat)
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
err = module.store.UpdateAccount(ctx, account)
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
// Get account as domain object for config access (enabled regions, etc.)
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
@@ -223,8 +243,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, err
}
// Delegate integration config building entirely to the provider module
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
if err != nil {
return nil, err
}
@@ -234,6 +253,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
account.ID.StringValue(),
integrationConfig,
account.RemovedAt,
syncState,
), nil
}

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

@@ -33,7 +33,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset",
"LIST_LICENSES": "SigNoz | List of Licenses",

View File

@@ -42,7 +42,6 @@
"NOT_FOUND": "SigNoz | Page Not Found",
"LOGS": "SigNoz | Logs",
"LOGS_EXPLORER": "SigNoz | Logs Explorer",
"LIVE_LOGS": "SigNoz | Live Logs",
"LOGS_PIPELINES": "SigNoz | Logs Pipelines",
"HOME_PAGE": "Open source Observability Platform | SigNoz",
"PASSWORD_RESET": "SigNoz | Password Reset",

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

@@ -300,14 +300,6 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-explorer/live to /logs/logs-explorer/live', () => {
renderPrivateRoute({ initialRoute: '/logs-explorer/live' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/logs-explorer/live',
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
@@ -1596,7 +1588,6 @@ describe('PrivateRoute', () => {
// route definition comes last, and both keys are authz-aware either way.
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
METRICS_EXPLORER: {
path: ROUTES.METRICS_EXPLORER,
deniedRoles: DENIED_ROLES,

View File

@@ -12,13 +12,14 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
@@ -58,7 +59,6 @@ function App(): JSX.Element {
isFetchingActiveLicense,
activeLicenseFetchError,
userFetchError,
featureFlagsFetchError,
isLoggedIn: isLoggedInState,
featureFlags,
org,
@@ -66,6 +66,8 @@ function App(): JSX.Element {
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isSavedViewEnabled = useSavedViewEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
@@ -253,7 +255,9 @@ function App(): JSX.Element {
}, [isDarkMode]);
useEffect(() => {
// The bottom strip carries Support, so the floating bubble goes entirely.
if (
isSavedViewEnabled ||
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
@@ -263,71 +267,32 @@ function App(): JSX.Element {
} else {
window.Pylon?.('showChatBubble');
}
}, [pathname]);
}, [pathname, isSavedViewEnabled]);
// eslint-disable-next-line sonarjs/cognitive-complexity
// Identity for the Pylon widget. Whether this user gets Pylon at all is
// `useChatSupport`'s call — this only fills in who they are.
useEffect(() => {
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
// to something went wrong which would ideally need a reload.
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
let isPremiumSupportEnabled = false;
if (featureFlags && featureFlags.length > 0) {
isChatSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
?.active || false;
isPremiumSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
?.active || false;
}
const showAddCreditCardModal =
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
if (
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) &&
window.signozBootData?.settings?.pylon?.enabled
) {
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}
if (chatSupport !== ChatSupportState.Pylon) {
return;
}
}, [
isLoggedInState,
user,
pathname,
trialInfo?.trialConvertedToSubscription,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
activeLicense,
trialInfo,
isCloudUser,
isEnterpriseSelfHostedUser,
]);
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}, [chatSupport, user]);
useEffect(() => {
if (!isFetchingUser && isCloudUser && user && user.email) {

View File

@@ -154,10 +154,6 @@ export const Logs = Loadable(
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
);
export const LiveLogs = Loadable(
() => import(/* webpackChunkName: "Live Logs" */ 'pages/LiveLogs'),
);
export const PipelinePage = Loadable(
() => import(/* webpackChunkName: "Pipelines" */ 'pages/LogsModulePage'),
);

View File

@@ -23,7 +23,6 @@ import {
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LiveLogs,
Login,
Logs,
LogsIndexToFields,
@@ -282,13 +281,6 @@ const routes: AppRoutes[] = [
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LIVE_LOGS,
exact: true,
component: LiveLogs,
key: 'LIVE_LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
@@ -521,7 +513,6 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-explorer/live': '/logs/logs-explorer/live',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/settings/access-tokens': '/settings/service-accounts',

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

@@ -104,9 +104,9 @@ export interface AlertmanagertypesChannelSlackFieldDTO {
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
* @type array,null
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
actions?: AlertmanagertypesChannelSlackActionDTO[] | null;
/**
* @type string
* @format password
@@ -125,9 +125,9 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
*/
fallback?: string;
/**
* @type array
* @type array,null
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
fields?: AlertmanagertypesChannelSlackFieldDTO[] | null;
/**
* @type string
*/
@@ -166,13 +166,19 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
email = 'email',
}
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
* @type object
* @type object,null
*/
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
/**
@@ -239,10 +245,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
pagerduty = 'pagerduty',
}
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @type string
@@ -265,7 +277,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
*/
description?: string;
/**
* @type object
* @type object,null
*/
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
/**
@@ -307,10 +319,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
opsgenie = 'opsgenie',
}
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @type string
@@ -326,7 +344,7 @@ export interface AlertmanagertypesChannelOpsgenieConfigDTO {
*/
description?: string;
/**
* @type object
* @type object,null
*/
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
/**
@@ -423,10 +441,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @type string
@@ -434,7 +458,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
*/
apiToken: string;
/**
* @type object
* @type object,null
*/
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
/**
@@ -450,9 +474,9 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
*/
issueType: string;
/**
* @type array
* @type array,null
*/
labels?: string[];
labels?: string[] | null;
/**
* @type string
*/
@@ -543,17 +567,23 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
incidentio = 'incidentio',
}
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @type string
*/
description?: string;
/**
* @type object
* @type object,null
*/
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
/**
@@ -3366,6 +3396,37 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
}
export enum CloudintegrationtypesRegionStateDTO {
enabled = 'enabled',
disabled = 'disabled',
}
export interface CloudintegrationtypesRegionSyncStateDTO {
state: CloudintegrationtypesRegionStateDTO;
}
export type CloudintegrationtypesSyncStateDTORegions = {
[key: string]: CloudintegrationtypesRegionSyncStateDTO;
};
/**
* @nullable
*/
export type CloudintegrationtypesSyncStateDTO = {
/**
* @type boolean
*/
inSync: boolean;
/**
* @type object
*/
regions: CloudintegrationtypesSyncStateDTORegions;
/**
* @type integer
* @format int64
*/
version: number;
} | null;
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
[key: string]: unknown;
};
@@ -3384,6 +3445,7 @@ export type CloudintegrationtypesAgentReportDTO = {
* @type object,null
*/
data: CloudintegrationtypesAgentReportDTOData;
syncState: CloudintegrationtypesSyncStateDTO | null;
/**
* @type integer
* @format int64
@@ -3812,6 +3874,7 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
* @format date-time
*/
removedAt: string | null;
syncState: CloudintegrationtypesSyncStateDTO | null;
}
export interface CloudintegrationtypesServiceMetadataDTO {
@@ -3882,6 +3945,10 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
* @type string
*/
providerAccountId?: string;
/**
* @type integer,null
*/
syncedVersion?: number | null;
}
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
@@ -5224,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
@@ -5233,7 +5381,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6159,6 +6308,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable
@@ -10261,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
@@ -10429,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 };
@@ -11127,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
@@ -12815,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
@@ -13716,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

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

@@ -0,0 +1,103 @@
import { useMutation } from 'react-query';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, X } from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
interface AddCreditCardModalProps {
open: boolean;
onClose: () => void;
onAddCreditCard?: () => void;
}
/**
* Shown to trial users who have not added a card, in place of chat support.
* Submitting creates the subscription and opens the returned billing URL.
*/
function AddCreditCardModal({
open,
onClose,
onAddCreditCard,
}: AddCreditCardModalProps): JSX.Element {
const { notifications } = useNotifications();
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
);
const handleAddCreditCard = (): void => {
onAddCreditCard?.();
updateCreditCard({ url: getBaseUrl() });
};
return (
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
open={open}
closable
onCancel={onClose}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={onClose}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>.
Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
);
}
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
export default AddCreditCardModal;

View File

@@ -0,0 +1,9 @@
import { rest } from 'msw';
const SUBSCRIPTIONS = 'http://localhost/api/v1/subscriptions';
export const createSubscriptionHandlers = [
rest.post(SUBSCRIPTIONS, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
),
];

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '@signozhq/ui/button';
import { screen, userEvent, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { createSubscriptionHandlers } from './AddCreditCardModal.stories.mocks';
import AddCreditCardModal from '../AddCreditCardModal';
/** Stands in for the surfaces that open it: the strip's Support button, and
* `LaunchChatSupport` in the custom domain and onboarding flows. */
function ModalFixture(): JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<Button
data-testid="open-add-credit-card"
onClick={(): void => setOpen(true)}
>
Contact support
</Button>
<AddCreditCardModal open={open} onClose={(): void => setOpen(false)} />
</>
);
}
const meta = {
title: 'Components/Add Credit Card Modal',
component: ModalFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
parameters: {
msw: { handlers: createSubscriptionHandlers },
},
} satisfies Meta<typeof ModalFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** What a trial user without a card sees in place of chat support. */
export const Default: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('open-add-credit-card'),
);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};

View File

@@ -1,63 +1,15 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Button } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { MessageSquareText } from '@signozhq/icons';
export default function ChatSupportGateway(): JSX.Element {
const { notifications } = useNotifications();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
},
);
const { pathname } = useLocation();
const handleAddCreditCard = (): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
updateCreditCard({
url: getBaseUrl(),
});
};
return (
<>
<div className="chat-support-gateway">
@@ -76,47 +28,16 @@ export default function ChatSupportGateway(): JSX.Element {
</Button>
</div>
{/* Add Credit Card Modal */}
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
<AddCreditCardModal
open={isAddCreditCardModalOpen}
closable
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={(): void => setIsAddCreditCardModalOpen(false)}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>
. Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={(): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
}}
/>
</>
);
}

View File

@@ -106,7 +106,7 @@ describe.each([
renderWithStore(dataSource);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
expect(button).toHaveAccessibleName('Download');
});
it('shows popover with export options when download button is clicked', () => {

View File

@@ -1,11 +1,12 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Tooltip } from 'antd';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { Download } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
import {
@@ -111,8 +112,9 @@ export default function DownloadOptionsMenu({
)}
<Button
type="primary"
icon={<Download size={16} />}
variant="solid"
color="primary"
prefix={<Download size={16} />}
onClick={handleExport}
className="export-button"
disabled={isDownloading}
@@ -144,16 +146,14 @@ export default function DownloadOptionsMenu({
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<LoaderCircle size={14} className="animate-spin" />
) : (
<Download size={14} />
)
}
variant="ghost"
color="secondary"
size="icon"
prefix={<Download size={14} />}
aria-label="Download"
data-testid={`periscope-btn-download-${dataSource}`}
disabled={isDownloading}
loading={isDownloading}
/>
</Tooltip>
</Popover>

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

@@ -1,22 +1,14 @@
import { useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Button, Tooltip } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { defaultTo } from 'lodash-es';
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
import { CircleHelp } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import './LaunchChatSupport.styles.scss';
@@ -41,7 +33,6 @@ function LaunchChatSupport({
chatMessageDisabled = false,
}: LaunchChatSupportProps): JSX.Element | null {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const { notifications } = useNotifications();
const {
trialInfo,
featureFlags,
@@ -119,43 +110,12 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
},
);
const handleAddCreditCard = (): void => {
logEvent('Add Credit card modal: Clicked', {
source: `facing issues button`,
page: pathname,
...attributes,
});
updateCreditCard({
url: getBaseUrl(),
});
};
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
@@ -175,47 +135,11 @@ function LaunchChatSupport({
</Button>
</Tooltip>
{/* Add Credit Card Modal */}
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
<AddCreditCardModal
open={isAddCreditCardModalOpen}
closable
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={(): void => setIsAddCreditCardModalOpen(false)}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>
. Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={handleAddCreditCard}
/>
</div>
) : null;
}

View File

@@ -1,7 +1,7 @@
import { DrawerProps } from 'antd';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -19,10 +19,6 @@ jest.mock('periscope/components/DataViewer', () => ({
}));
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
}));
const mockLog: ILog = {
id: 'log-1',
timestamp: '2024-01-15T09:45:30Z',
@@ -58,7 +54,7 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
);
}
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
describe('LogDetail drawer — header', () => {
afterEach(() => {
jest.clearAllMocks();
localStorage.clear();

View File

@@ -1,16 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useCopyToClipboard } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import LogStateIndicator, {
LogType,
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -19,33 +13,24 @@ import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
getSanitizedLogBody,
removeEscapeCharacters,
} from 'container/LogDetailedView/utils';
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
Braces,
ChevronDown,
ChevronUp,
Compass,
Copy,
Filter,
Histogram,
Table,
TextSelect,
X,
} from '@signozhq/icons';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -56,7 +41,6 @@ import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -64,11 +48,8 @@ import './LogDetails.styles.scss';
function LogDetailInner({
log,
onClose,
onAddToQuery,
onClickActionItem,
selectedTab,
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
@@ -81,7 +62,6 @@ function LogDetailInner({
const [contextQuery, setContextQuery] = useState<Query | undefined>(
initialContextQuery,
);
const [, copyToClipboard] = useCopyToClipboard();
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
@@ -94,8 +74,6 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -173,12 +151,6 @@ function LogDetailInner({
const isDarkMode = useIsDarkMode();
const { notifications } = useNotifications();
const { onLogCopy } = useCopyLogLink(log?.id);
const LogJsonData = log ? aggregateAttributesResourcesToString(log) : '';
const handleModeChange = (value: string): void => {
setSelectedView(value as VIEWS);
setIsEdit(false);
@@ -220,13 +192,6 @@ function LogDetailInner({
[logBody],
);
const handleJSONCopy = (): void => {
copyToClipboard(LogJsonData);
notifications.success({
message: 'Copied to clipboard',
});
};
const handleQueryExpressionChange = useCallback(
(value: string, queryIndex: number) => {
// update the query at the given index
@@ -295,8 +260,6 @@ function LogDetailInner({
}
};
const logType = log?.attributes_string?.log_level || LogType.INFO;
return (
<Drawer
width="60%"
@@ -304,69 +267,15 @@ function LogDetailInner({
maskClosable={false}
getContainer={getContainer}
title={
isLogDetailsV2 ? (
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
) : (
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={goToPrev}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
variant="outlined"
color="secondary"
prefix={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={goToNext}
/>
</Tooltip>
</div>
{handleOpenInExplorer && (
<div>
<Button
variant="outlined"
color="secondary"
prefix={<Compass size={16} />}
className="open-in-explorer-btn"
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
</div>
)
<LogDetailsHeader
log={log}
onNavigatePrev={goToPrev}
onNavigateNext={goToNext}
isPrevDisabled={isPrevDisabled}
isNextDisabled={isNextDisabled}
showOpenInExplorer={!!handleOpenInExplorer}
onOpenInExplorer={handleOpenInExplorer}
/>
}
placement="right"
onClose={drawerCloseHandler}
@@ -385,15 +294,11 @@ function LogDetailInner({
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
{isLogDetailsV2 ? (
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
) : (
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
)}
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
@@ -405,9 +310,9 @@ function LogDetailInner({
<div className="log-overflow-shadow">&nbsp;</div>
</div>
{isLogDetailsV2 && <LogHighlights log={log} />}
<LogHighlights log={log} />
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
@@ -425,21 +330,6 @@ function LogDetailInner({
</div>
),
},
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
// JSON tab is redundant.
...(isLogDetailsV2
? []
: [
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
]),
{
value: VIEW_TYPES.CONTEXT,
label: (
@@ -478,26 +368,6 @@ function LogDetailInner({
/>
</Tooltip>
)}
{/* V2 moves copy actions into the header ⋯ menu */}
{!isLogDetailsV2 && (
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Copy size={12} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
@@ -514,19 +384,11 @@ function LogDetailInner({
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}

View File

@@ -1,11 +0,0 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return (
pathname === ROUTES.LOGS_EXPLORER ||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
);
}

View File

@@ -1,54 +0,0 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Popover } from 'antd';
import { useNotifications } from 'hooks/useNotifications';
function CopyClipboardHOC({
entityKey,
textToCopy,
tooltipText = 'Copy to clipboard',
children,
}: CopyClipboardHOCProps): JSX.Element {
const [value, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
useEffect(() => {
if (value.value) {
const key = entityKey || '';
const notificationMessage = `${key} copied to clipboard`;
notifications.success({
message: notificationMessage,
key: notificationMessage,
});
}
}, [value, notifications, entityKey]);
const onClick = useCallback((): void => {
setCopy(textToCopy);
}, [setCopy, textToCopy]);
return (
<span onClick={onClick} role="presentation" tabIndex={-1}>
<Popover
placement="top"
overlayClassName="drawer-popover"
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
>
{children}
</Popover>
</span>
);
}
interface CopyClipboardHOCProps {
entityKey: string | undefined;
textToCopy: string;
tooltipText?: string;
children: ReactNode;
}
export default CopyClipboardHOC;
CopyClipboardHOC.defaultProps = {
tooltipText: 'Copy to clipboard',
};

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

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

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

@@ -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 +1,82 @@
// Code generated by scripts/semconv. DO NOT EDIT.
export type SemconvFamily = {
readonly current: string;
readonly old: readonly string[];
readonly kind: 'attribute' | 'metric';
// An empty contexts/signals/applyToMetrics array places no constraint on
// that axis.
export type SemconvMember = {
readonly name: string;
readonly contexts: readonly string[];
readonly signals: readonly string[];
readonly applyToMetrics: readonly string[];
};
export type SemconvFamily = {
readonly current: string;
readonly kind: 'attribute' | 'metric';
readonly members: readonly SemconvMember[];
readonly contexts: readonly string[];
readonly signals: readonly string[];
readonly valueMap: Readonly<Record<string, string>>;
};
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
{
current: 'db.system.name',
old: ['db.system'],
kind: 'attribute',
current: 'container.cpu.usage',
kind: 'metric',
members: [
{
name: 'container.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
applyToMetrics: [],
valueMap: {},
},
{
current: 'deployment.environment.name',
old: ['deployment.environment'],
kind: 'attribute',
members: [
{
name: 'deployment.environment',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: ['attribute', 'resource'],
signals: ['logs', 'metrics', 'traces'],
valueMap: {},
},
{
current: 'k8s.node.cpu.usage',
kind: 'metric',
members: [
{
name: 'k8s.node.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
valueMap: {},
},
{
current: 'k8s.pod.cpu.usage',
kind: 'metric',
members: [
{
name: 'k8s.pod.cpu.utilization',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: [],
signals: [],
applyToMetrics: [],
valueMap: {},
},
] as const;

View File

@@ -47,4 +47,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

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

@@ -38,7 +38,6 @@ const ROUTES = {
LOGS_BASE: '/logs',
LOGS: '/logs/logs-explorer',
LOGS_EXPLORER: '/logs/logs-explorer',
LIVE_LOGS: '/logs/logs-explorer/live',
LOGS_PIPELINES: '/logs/pipelines',
PASSWORD_RESET: '/password-reset',
LIST_LICENSES: '/licenses',

View File

@@ -75,6 +75,38 @@
word-break: break-word;
}
// Error bubble: a subtle error-tinted callout replacing the default
// assistant background, rendered when a turn fails.
.bubble.error {
.assistant & {
background: var(--callout-error-background);
border: 1px solid var(--callout-error-border);
}
}
.errorContent {
display: flex;
align-items: flex-start;
gap: 8px;
}
.errorIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--destructive);
}
.errorText {
color: var(--callout-error-title);
white-space: pre-wrap;
word-break: break-word;
}
.retryButton {
margin-top: 6px;
align-self: flex-start;
}
// User-bubble row: pencil button sits to the LEFT of the bubble within
// the right-aligned message line, so it visually "ends" at the bubble's
// right edge while keeping the bubble in its original position.

View File

@@ -2,6 +2,10 @@ import React, { useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Button } from '@signozhq/ui/button';
import { RotateCw, TriangleAlert } from '@signozhq/icons';
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
// Side-effect: registers all built-in block types into the BlockRegistry
import '../blocks';
@@ -104,18 +108,23 @@ function renderGroup(group: RenderGroup): JSX.Element {
interface MessageBubbleProps {
message: Message;
onRegenerate?: () => void;
onRetry?: () => void;
isLastAssistant?: boolean;
}
export default function MessageBubble({
message,
onRegenerate,
onRetry,
isLastAssistant = false,
}: MessageBubbleProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const isUser = message.role === 'user';
const isError = !isUser && Boolean(message.isError);
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
const showRetry =
isError && message.retryAction === RetryActionDTO.manual && Boolean(onRetry);
// Recompute groups only when the blocks array identity changes — store
// updates that don't touch this message's blocks should not re-render the
@@ -138,7 +147,7 @@ export default function MessageBubble({
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
<div className={bodyClass}>
<div className={styles.bubbleRow}>
<div className={styles.bubble}>
<div className={cx(styles.bubble, { [styles.error]: isError })}>
{message.attachments && message.attachments.length > 0 && (
<div className={styles.attachments}>
{message.attachments.map((att) => {
@@ -161,6 +170,11 @@ export default function MessageBubble({
{isUser ? (
<p className={styles.text}>{message.content}</p>
) : isError ? (
<div className={styles.errorContent}>
<TriangleAlert size={14} className={styles.errorIcon} />
<span className={styles.errorText}>{message.content}</span>
</div>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{groups.map((g) => renderGroup(g))}
@@ -183,7 +197,21 @@ export default function MessageBubble({
</div>
</div>
{!isUser && !message.isRateLimitError && (
{showRetry && (
<Button
className={styles.retryButton}
size="sm"
variant="ghost"
color="secondary"
onClick={onRetry}
testId={`ai-message-retry-${message.id}`}
>
<RotateCw size={12} />
Retry
</Button>
)}
{!isUser && !isError && !message.isRateLimitError && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { render, screen, userEvent } from 'tests/test-utils';
import {
ErrorCodeDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Message } from '../../../types';
// react-markdown + remark-gfm are ESM-only and pull a large untransformed
// dependency chain into jest. The error-rendering path under test renders
// plain text (no markdown), so stub them to keep the import graph loadable.
jest.mock('react-markdown', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }): React.ReactNode =>
children,
}));
jest.mock('remark-gfm', () => ({
__esModule: true,
default: (): void => undefined,
}));
// eslint-disable-next-line import/first
import MessageBubble from '../MessageBubble';
function errorMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'err-1',
role: 'assistant',
content: 'This conversation is still finishing a previous response.',
isError: true,
errorCode: ErrorCodeDTO.thread_busy,
retryAction: RetryActionDTO.manual,
createdAt: 0,
...overrides,
};
}
const retryButton = (): HTMLElement | null =>
screen.queryByRole('button', { name: /retry/i });
describe('MessageBubble — error rendering', () => {
it('shows a Retry button for a manual error and invokes onRetry on click', async () => {
const onRetry = jest.fn();
render(<MessageBubble message={errorMessage()} onRetry={onRetry} />);
// Error copy is rendered, and the feedback bar is suppressed on errors.
expect(
screen.getByText(/still finishing a previous response/i),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /copy message/i }),
).not.toBeInTheDocument();
const button = retryButton();
expect(button).toBeInTheDocument();
await userEvent.click(button as HTMLElement);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('hides the Retry button when retryAction is none', () => {
render(
<MessageBubble
message={errorMessage({ retryAction: RetryActionDTO.none })}
onRetry={jest.fn()}
/>,
);
expect(retryButton()).not.toBeInTheDocument();
});
it('hides the Retry button when retryAction is auto', () => {
render(
<MessageBubble
message={errorMessage({ retryAction: RetryActionDTO.auto })}
onRetry={jest.fn()}
/>,
);
expect(retryButton()).not.toBeInTheDocument();
});
it('hides the Retry button when no onRetry handler is provided', () => {
render(<MessageBubble message={errorMessage()} />);
expect(retryButton()).not.toBeInTheDocument();
});
});

View File

@@ -37,6 +37,9 @@ export default function VirtualizedMessages({
const regenerateAssistantMessage = useAIAssistantStore(
(s) => s.regenerateAssistantMessage,
);
const retryAssistantMessage = useAIAssistantStore(
(s) => s.retryAssistantMessage,
);
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
@@ -85,6 +88,14 @@ export default function VirtualizedMessages({
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
);
const handleRetry = useCallback((): void => {
if (isStreaming) {
return;
}
void logEvent(AIAssistantEvents.RetryClicked, { threadId });
void retryAssistantMessage(conversationId);
}, [conversationId, isStreaming, retryAssistantMessage, threadId]);
// Scroll all the way to the actual bottom — including the 64px of bottom
// padding on the scroller — so the last bubble has visible breathing room
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
@@ -206,6 +217,11 @@ export default function VirtualizedMessages({
? (): void => handleRegenerate(msg.id)
: undefined
}
onRetry={
msg.isError && isLastAssistant && !showStreamingSlot
? handleRetry
: undefined
}
isLastAssistant={isLastAssistant}
/>
);

View File

@@ -90,6 +90,7 @@ export enum AIAssistantEvents {
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
CancelClicked = 'AI Assistant: Cancel clicked',
RegenerateClicked = 'AI Assistant: Regenerate clicked',
RetryClicked = 'AI Assistant: Retry clicked',
MessageCopied = 'AI Assistant: Message copied',
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
ResourceOpened = 'AI Assistant: Resource opened',

View File

@@ -0,0 +1,263 @@
import {
ErrorCodeDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type { SSEEvent } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from '../useAIAssistantStore';
import type { Message } from '../../types';
// The store talks to the chat API only through these named exports. Mock the
// whole module so we can drive the SSE stream + REST calls deterministically.
jest.mock('api/ai-assistant/chat', () => ({
__esModule: true,
createThread: jest.fn(),
sendMessage: jest.fn(),
streamEvents: jest.fn(),
approveExecution: jest.fn(),
clarifyExecution: jest.fn(),
regenerateMessage: jest.fn(),
rejectExecution: jest.fn(),
cancelExecution: jest.fn(),
listThreads: jest.fn(),
getThreadDetail: jest.fn(),
updateThread: jest.fn(),
submitFeedback: jest.fn(),
}));
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
const chat = jest.requireMock('api/ai-assistant/chat') as Record<
string,
jest.Mock
>;
// Builds a single-use async stream from a fixed list of SSE events.
async function* eventStream(events: SSEEvent[]): AsyncGenerator<SSEEvent> {
for (const event of events) {
yield event;
}
}
function errorEvent(
executionId: string,
code: ErrorCodeDTO,
retryAction: RetryActionDTO,
): SSEEvent {
return {
type: 'error',
executionId,
error: { code, message: 'backend message' },
retryAction,
};
}
function lastMessage(conversationId: string): Message {
const conv = useAIAssistantStore.getState().conversations[conversationId];
return conv.messages[conv.messages.length - 1];
}
describe('useAIAssistantStore — streaming error handling', () => {
beforeEach(() => {
jest.clearAllMocks();
useAIAssistantStore.setState((s) => {
s.conversations = {};
s.streams = {};
s.activeConversationId = null;
});
});
it('commits a manually-retryable error bubble with friendly copy and metadata', async () => {
chat.createThread.mockResolvedValue('thread-1');
chat.sendMessage.mockResolvedValue('exec-1');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hello');
const conv = useAIAssistantStore.getState().conversations['thread-1'];
expect(conv.messages).toHaveLength(2);
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
expect(conv.messages[1]).toMatchObject({
role: 'assistant',
isError: true,
errorCode: ErrorCodeDTO.thread_busy,
retryAction: RetryActionDTO.manual,
});
// Code-specific FE copy, not the raw backend message.
expect(conv.messages[1].content).toContain(
'still finishing a previous response',
);
});
it('replays the send on retry without re-pushing the user message', async () => {
chat.createThread.mockResolvedValue('thread-1');
chat.sendMessage.mockResolvedValue('exec-1');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hello');
// The retry succeeds this time.
chat.streamEvents.mockReturnValueOnce(
eventStream([
{
type: 'message',
executionId: 'exec-1',
messageId: 'm1',
delta: 'Hi there',
done: true,
},
]),
);
await useAIAssistantStore.getState().retryAssistantMessage('thread-1');
const conv = useAIAssistantStore.getState().conversations['thread-1'];
// Error bubble replaced by the assistant reply; the user message stays.
expect(conv.messages).toHaveLength(2);
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
expect(conv.messages[1]).toMatchObject({
role: 'assistant',
content: 'Hi there',
});
expect(conv.messages[1].isError).toBeUndefined();
// Thread already existed on retry; the user message was never re-sent as new.
expect(chat.createThread).toHaveBeenCalledTimes(1);
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
});
it('silently retries auto-flagged errors, then downgrades to manual once spent', async () => {
chat.createThread.mockResolvedValue('thread-2');
chat.sendMessage.mockResolvedValue('exec');
// Always auto-retryable: 1 initial attempt + MAX_AUTO_RETRIES (2) = 3 sends.
chat.streamEvents.mockImplementation(() =>
eventStream([
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hi');
expect(chat.sendMessage).toHaveBeenCalledTimes(3);
expect(lastMessage('thread-2')).toMatchObject({
isError: true,
errorCode: ErrorCodeDTO.internal_error,
// Auto budget exhausted → presented as manual so a Retry button shows.
retryAction: RetryActionDTO.manual,
});
}, 10000);
it('marks rate-limit errors and offers no retry', async () => {
chat.createThread.mockResolvedValue('thread-3');
chat.sendMessage.mockResolvedValue('exec');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec', ErrorCodeDTO.hourly_message_limit, RetryActionDTO.none),
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hi');
expect(lastMessage('thread-3')).toMatchObject({
isError: true,
isRateLimitError: true,
retryAction: RetryActionDTO.none,
});
// No retry thunk registered for a non-retryable error — retry is a no-op.
const before =
useAIAssistantStore.getState().conversations['thread-3'].messages.length;
await useAIAssistantStore.getState().retryAssistantMessage('thread-3');
expect(
useAIAssistantStore.getState().conversations['thread-3'].messages,
).toHaveLength(before);
});
it('recovers silently when an auto-flagged error succeeds on retry', async () => {
chat.createThread.mockResolvedValue('thread-4');
chat.sendMessage.mockResolvedValue('exec');
chat.streamEvents
.mockReturnValueOnce(
eventStream([
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
]),
)
.mockReturnValueOnce(
eventStream([
{
type: 'message',
executionId: 'exec',
messageId: 'm1',
delta: 'Recovered',
done: true,
},
]),
);
useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().sendMessage('hi');
// 1 initial attempt + 1 silent auto retry, then success — no error bubble.
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
const conv = useAIAssistantStore.getState().conversations['thread-4'];
expect(conv.messages).toHaveLength(2);
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hi' });
expect(conv.messages[1]).toMatchObject({
role: 'assistant',
content: 'Recovered',
});
expect(conv.messages.some((m) => m.isError)).toBe(false);
}, 10000);
it('replays the originating action on retry for a non-send error (approve)', async () => {
chat.approveExecution.mockResolvedValue('exec-a');
chat.streamEvents.mockReturnValueOnce(
eventStream([
errorEvent('exec-a', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
]),
);
const convId = useAIAssistantStore.getState().startNewConversation();
await useAIAssistantStore.getState().approveAction(convId, 'approval-1');
expect(lastMessage(convId)).toMatchObject({
isError: true,
retryAction: RetryActionDTO.manual,
});
expect(chat.approveExecution).toHaveBeenCalledTimes(1);
// Retry replays the approval (not a send) and succeeds this time.
chat.streamEvents.mockReturnValueOnce(
eventStream([
{
type: 'message',
executionId: 'exec-a',
messageId: 'm1',
delta: 'Approved',
done: true,
},
]),
);
await useAIAssistantStore.getState().retryAssistantMessage(convId);
const conv = useAIAssistantStore.getState().conversations[convId];
expect(conv.messages).toHaveLength(1);
expect(conv.messages[0]).toMatchObject({
role: 'assistant',
content: 'Approved',
});
expect(conv.messages[0].isError).toBeUndefined();
expect(chat.approveExecution).toHaveBeenCalledTimes(2);
expect(chat.sendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,7 @@ import type {
MessageActionDTO,
MessageSummaryDTOBlocksAnyOfItem,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
approveExecution,
@@ -35,7 +36,10 @@ import {
MessageBlock,
MessageRole,
} from '../types';
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
import {
resolveAssistantError,
type AssistantErrorResolution,
} from '../utils/resolveAssistantError';
// ---------------------------------------------------------------------------
// Types used by module-level helpers
@@ -56,6 +60,15 @@ interface SSEStreamCtx {
const streamControllers = new Map<string, AbortController>();
/**
* Per-conversation retry thunks for the most recent failed turn. Populated by
* `finalizeStreamingError` when the error is manually retryable; consumed by
* the `retryAssistantMessage` action when the user clicks Retry. Transient
* (not persisted) — it shares the in-memory lifetime of the error bubble it
* backs, so a page reload drops both together.
*/
const retryRegistry = new Map<string, () => Promise<void>>();
function abortStream(conversationId: string): void {
const ctrl = streamControllers.get(conversationId);
if (ctrl) {
@@ -197,7 +210,7 @@ function resetStreamingState(
* Marker thrown by `runStreamingLoop` when an SSE event reports
* `invalid_token`. Callers that own an originating action (sendMessage /
* approve / clarify / regenerate) catch this and re-issue that action via
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
* `streamWithRetry`; the retry's first REST call will 401, at which point
* the shared axios `interceptorRejected` rotates the access token and replays.
*/
class AuthExpiredError extends Error {
@@ -207,27 +220,50 @@ class AuthExpiredError extends Error {
}
}
/** Capped silent re-attempts for backend-flagged transient (`auto`) errors. */
const MAX_AUTO_RETRIES = 2;
/** Backoff before each auto re-attempt, indexed by prior auto-retry count. */
const AUTO_RETRY_BACKOFF_MS = [500, 1500];
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** True when the SSE error carries the backend's `retryAction: 'auto'` flag. */
function isAutoRetryableError(err: unknown): boolean {
return (
(err as { retryAction?: unknown } | undefined)?.retryAction ===
RetryActionDTO.auto
);
}
/**
* Runs the originating action (e.g. sendMessage POST) and streams the
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
* retry's REST call hits 401, the shared axios interceptor rotates the
* access token and replays, and the new SSE picks up the rotated token from
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
* so the dead execution can't be resumed — only a fresh one helps.
* resulting execution, with two independent retry budgets:
*
* • Auth — on `AuthExpiredError` (SSE `invalid_token`), re-issues `start`
* once. The retry's REST call 401s, the shared axios interceptor rotates
* the access token + replays, and the new SSE picks up the rotated token.
* Backend flags `invalid_token` as `manual`, so only a fresh execution helps.
* • Auto — on an SSE error the backend flagged `retryAction: 'auto'`
* (transient), silently re-issues `start` up to `MAX_AUTO_RETRIES` times
* with backoff. Once exhausted the error propagates so the caller can
* surface a manual Retry affordance.
*
* Both reset the stream state before re-attempting so a dead execution's
* partial output isn't concatenated onto the retry.
*/
async function streamWithAuthRetry(
async function streamWithRetry(
conversationId: string,
start: () => Promise<string>,
set: StoreSetter,
): Promise<void> {
for (let attempt = 0; attempt <= 1; attempt += 1) {
if (attempt > 0) {
// Drop any partial content/events from the previous attempt so the
// retried execution's stream isn't concatenated with the dead one.
set((s) => {
resetStreamingState(s, conversationId);
});
}
let authRetried = false;
let autoRetries = 0;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const executionId = await start();
const ctrl = newStreamController(conversationId);
@@ -242,10 +278,28 @@ async function streamWithAuthRetry(
return;
} catch (err) {
streamControllers.delete(conversationId);
if (err instanceof AuthExpiredError && attempt < 1) {
continue;
if (err instanceof AuthExpiredError && !authRetried) {
authRetried = true;
} else if (isAutoRetryableError(err) && autoRetries < MAX_AUTO_RETRIES) {
// eslint-disable-next-line no-await-in-loop
await delay(AUTO_RETRY_BACKOFF_MS[autoRetries] ?? 1500);
autoRetries += 1;
} else {
if (isAutoRetryableError(err)) {
// Auto-retry budget spent — present the failure as manually
// retryable so the caller surfaces a Retry button rather than
// silently giving up.
(err as { retryAction?: RetryActionDTO }).retryAction =
RetryActionDTO.manual;
}
throw err;
}
throw err;
// Drop partial content/events from the failed attempt before retrying.
set((s) => {
resetStreamingState(s, conversationId);
});
}
}
}
@@ -258,7 +312,7 @@ async function streamWithAuthRetry(
*
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
* throws `AuthExpiredError` so the caller can re-issue the originating
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
* action via `streamWithRetry`. We don't refresh here ourselves — the
* retry's REST call will 401 and the shared axios `interceptorRejected`
* handles rotation + replay. Throws on any other `error` event — the
* caller's catch block handles UI feedback.
@@ -484,20 +538,37 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
}
/**
* Commits a failed turn as an error message and removes the stream entry.
* When the failure is manually retryable and a `retry` thunk is supplied, the
* thunk is stashed in `retryRegistry` so the bubble's Retry button can replay
* the originating action.
*/
function finalizeStreamingError(
conversationId: string,
errorContent: string,
resolution: AssistantErrorResolution,
set: StoreSetter,
isRateLimit = false,
retry?: () => Promise<void>,
): void {
const { message, code, retryAction, isRateLimit } = resolution;
if (retryAction === RetryActionDTO.manual && retry) {
retryRegistry.set(conversationId, retry);
} else {
retryRegistry.delete(conversationId);
}
set((s) => {
const conv = s.conversations[conversationId];
if (conv) {
conv.messages.push({
id: uuidv4(),
role: 'assistant',
content: errorContent,
content: message,
createdAt: Date.now(),
isError: true,
retryAction,
...(code ? { errorCode: code } : {}),
...(isRateLimit ? { isRateLimitError: true } : {}),
});
conv.updatedAt = Date.now();
@@ -506,6 +577,40 @@ function finalizeStreamingError(
});
}
/**
* Shared streaming wrapper for actions that have no pre-stream setup beyond
* resetting state (approve / clarify / regenerate). Streams the execution,
* finalizes the message on success, and on failure resolves the error +
* registers `retry` (the caller's own re-invocation) so the bubble can replay
* it. `sendMessage` does not use this — it owns thread-creation/re-keying and
* runs its own equivalent loop.
*/
async function streamAndFinalize(
conversationId: string,
start: () => Promise<string>,
fallback: string,
logLabel: string,
set: StoreSetter,
get: StoreGetter,
retry: () => Promise<void>,
): Promise<void> {
try {
await streamWithRetry(conversationId, start, set);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
// Abort errors are expected when the user cancels — not a failure.
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
// eslint-disable-next-line no-console
console.error(logLabel, err);
const resolution = resolveAssistantError(err, fallback);
finalizeStreamingError(conversationId, resolution, set, retry);
}
}
// ---------------------------------------------------------------------------
// Store interface
// ---------------------------------------------------------------------------
@@ -564,6 +669,8 @@ export interface AIAssistantStore {
conversationId: string,
messageId: string,
) => Promise<void>;
/** Replays the originating action for a manually-retryable error bubble. */
retryAssistantMessage: (conversationId: string) => Promise<void>;
submitMessageFeedback: (
messageId: string,
rating: FeedbackRating,
@@ -877,7 +984,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
// there's no "originating action" to redo — reopening the
// same dead executionId would just re-emit the failure.
// Let the error bubble; the user can send a new message,
// which will go through `streamWithAuthRetry`.
// which will go through `streamWithRetry`.
if (
detail.activeExecutionId &&
!streamControllers.has(threadId) &&
@@ -1060,7 +1167,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
attachments?: MessageAttachment[],
contexts?: MessageContext[],
): Promise<void> => {
let convId = get().activeConversationId;
const convId = get().activeConversationId;
if (!convId || !get().conversations[convId]) {
return;
}
@@ -1093,63 +1200,75 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
};
set((state) => {
const conv = state.conversations[convId!];
const conv = state.conversations[convId];
conv.messages.push(userMessage);
conv.updatedAt = Date.now();
if (!conv.title && text.trim()) {
conv.title = deriveTitle(text);
}
resetStreamingState(state, convId!);
resetStreamingState(state, convId);
});
try {
let { threadId } = get().conversations[convId];
if (!threadId) {
threadId = await createThread();
// Re-key the conversation from client UUID to backend threadId
// so fetchThreads won't create a duplicate entry later.
const oldId = convId;
convId = threadId;
set((s) => {
const conv = s.conversations[oldId];
if (conv) {
conv.id = convId!;
conv.threadId = convId!;
s.conversations[convId!] = conv;
delete s.conversations[oldId];
if (s.activeConversationId === oldId) {
s.activeConversationId = convId!;
// The full send — ensure a backend thread exists (re-keying the
// optimistic client UUID on first send), POST the message, and
// stream the reply. Defined as a closure so the error bubble's
// Retry button can replay it without re-pushing the user message.
const runSend = async (cid: string): Promise<void> => {
let targetConvId = cid;
try {
let { threadId } = get().conversations[targetConvId];
if (!threadId) {
threadId = await createThread();
// Re-key the conversation from client UUID to backend threadId
// so fetchThreads won't create a duplicate entry later.
const oldId = targetConvId;
const newId = threadId;
set((s) => {
const conv = s.conversations[oldId];
if (conv) {
conv.id = newId;
conv.threadId = newId;
s.conversations[newId] = conv;
delete s.conversations[oldId];
if (s.activeConversationId === oldId) {
s.activeConversationId = newId;
}
const stream = s.streams[oldId];
if (stream) {
s.streams[newId] = stream;
delete s.streams[oldId];
}
}
const stream = s.streams[oldId];
if (stream) {
s.streams[convId!] = stream;
delete s.streams[oldId];
}
}
});
}
const tid = threadId;
await streamWithAuthRetry(
convId,
() => sendMessageToThread(tid, text, contexts),
set,
);
});
targetConvId = newId;
}
const tid = threadId;
await streamWithRetry(
targetConvId,
() => sendMessageToThread(tid, text, contexts),
set,
);
if (!hasPendingInput(convId, get)) {
finalizeStreamingMessage(convId, set, get);
if (!hasPendingInput(targetConvId, get)) {
finalizeStreamingMessage(targetConvId, set, get);
}
} catch (err) {
// Abort errors are expected when the user cancels — not a failure.
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const resolution = resolveAssistantError(
err,
'Something went wrong while fetching the response. Please try again.',
);
finalizeStreamingError(targetConvId, resolution, set, () =>
runSend(targetConvId),
);
}
} catch (err) {
// Abort errors are expected when the user cancels — not a failure
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] sendMessage failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while fetching the response. Please try again.',
);
finalizeStreamingError(convId, message, set, isRateLimit);
}
};
await runSend(convId);
},
approveAction: async (
@@ -1167,26 +1286,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
try {
await streamWithAuthRetry(
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => approveExecution(approvalId),
set,
);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] approveAction failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing the approval. Please try again.',
'[AIAssistant] approveAction failed:',
set,
get,
run,
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
await run();
},
rejectAction: async (
@@ -1246,26 +1356,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
resetStreamingState(s, conversationId);
});
try {
await streamWithAuthRetry(
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => regenerateMessage(messageId),
set,
);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while regenerating the response. Please try again.',
'[AIAssistant] regenerateAssistantMessage failed:',
set,
get,
run,
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
}
await run();
},
submitMessageFeedback: async (
@@ -1312,26 +1413,42 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
try {
await streamWithAuthRetry(
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => clarifyExecution(clarificationId, answers),
set,
);
if (!hasPendingInput(conversationId, get)) {
finalizeStreamingMessage(conversationId, set, get);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
console.error('[AIAssistant] submitClarification failed:', err);
const { message, isRateLimit } = resolveAssistantErrorMessage(
err,
'Something went wrong while processing your answers. Please try again.',
'[AIAssistant] submitClarification failed:',
set,
get,
run,
);
finalizeStreamingError(conversationId, message, set, isRateLimit);
await run();
},
retryAssistantMessage: async (conversationId: string): Promise<void> => {
const retry = retryRegistry.get(conversationId);
if (!retry) {
return;
}
retryRegistry.delete(conversationId);
// Drop the trailing error bubble we're retrying from and reset the
// stream so the in-progress retry renders immediately. The retry
// thunk replays the originating action without re-pushing the
// user's message.
set((s) => {
const conv = s.conversations[conversationId];
if (conv) {
const last = conv.messages[conv.messages.length - 1];
if (last?.isError) {
conv.messages.pop();
}
}
resetStreamingState(s, conversationId);
});
await retry();
},
})),
{

View File

@@ -15,9 +15,11 @@
import type {
ApprovalEventDTO,
ClarificationEventDTO,
ErrorCodeDTO,
FeedbackRatingDTO,
MessageActionDTO,
MessageActionKindDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
/** Client-only file attachment — no API equivalent (uploads happen via data URLs). */
@@ -91,6 +93,18 @@ export interface Message {
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
*/
isRateLimitError?: boolean;
/**
* Marks an assistant message that represents a failed turn. Drives the
* error styling and replaces the feedback bar with a retry affordance.
*/
isError?: boolean;
/** Known backend error code for the failure, when recognised. */
errorCode?: ErrorCodeDTO;
/**
* Retry semantics for a failed turn — `manual` renders an inline Retry
* button on the error bubble; `none`/`auto` render no button.
*/
retryAction?: RetryActionDTO;
createdAt: number;
}

View File

@@ -0,0 +1,154 @@
import { AxiosError } from 'axios';
import {
ErrorCodeDTO,
RetryActionDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { resolveAssistantError } from '../resolveAssistantError';
const FALLBACK = 'Something went wrong. Please try again.';
function restError(status: number, code: string, message: string): AxiosError {
const err = new AxiosError('Request failed');
err.response = {
status,
data: { error: { code, message } },
} as AxiosError['response'];
return err;
}
describe('resolveAssistantError', () => {
describe('message resolution', () => {
it('prefers code-specific FE copy over the backend message', () => {
const err = restError(409, ErrorCodeDTO.thread_busy, 'raw backend phrasing');
const { message } = resolveAssistantError(err, FALLBACK);
expect(message).toBe(
'This conversation is still finishing a previous response. Give it a moment and try again.',
);
});
it('falls through to the backend message for a known code without FE copy', () => {
const err = restError(
400,
ErrorCodeDTO.message_not_found,
'No such message exists.',
);
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: 'No such message exists.',
code: ErrorCodeDTO.message_not_found,
retryAction: RetryActionDTO.none,
isRateLimit: false,
});
});
it('falls back when the error code is not in ErrorCodeDTO', () => {
const err = restError(400, 'future_unknown_code', 'Backend-only message');
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
code: undefined,
retryAction: RetryActionDTO.none,
isRateLimit: false,
});
});
});
describe('rate limiting', () => {
it('marks HTTP 429 responses as rate limited and non-retryable', () => {
const err = restError(
429,
ErrorCodeDTO.hourly_message_limit,
'Hourly limit reached.',
);
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: "You've reached the hourly message limit. Please try again later.",
code: ErrorCodeDTO.hourly_message_limit,
retryAction: RetryActionDTO.none,
isRateLimit: true,
});
});
it('treats known SSE rate-limit codes as rate limited', () => {
const err = Object.assign(new Error('Daily token limit exceeded.'), {
code: ErrorCodeDTO.daily_token_limit,
});
const res = resolveAssistantError(err, FALLBACK);
expect(res.isRateLimit).toBe(true);
expect(res.retryAction).toBe(RetryActionDTO.none);
});
it('marks 429 as rate limited even when the code is unknown', () => {
const err = restError(429, 'future_unknown_code', 'Too many requests');
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
code: undefined,
retryAction: RetryActionDTO.none,
isRateLimit: true,
});
});
});
describe('retryAction resolution', () => {
it('honours an explicit retryAction from an SSE error event', () => {
const err = Object.assign(new Error('Transient hiccup'), {
code: ErrorCodeDTO.internal_error,
retryAction: RetryActionDTO.auto,
});
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.auto,
);
});
it('forces none for non-retryable permission errors', () => {
const err = restError(403, ErrorCodeDTO.permission_denied, 'forbidden');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.none,
);
});
it('derives manual for 409 conflicts', () => {
const err = restError(409, ErrorCodeDTO.thread_has_active_execution, 'busy');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
it('derives manual for 5xx responses', () => {
const err = restError(503, 'future_unknown_code', 'unavailable');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
it('derives manual for network failures with no response', () => {
const err = new AxiosError('Network Error');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
it('derives none for other 4xx responses', () => {
const err = restError(400, 'future_unknown_code', 'bad request');
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
RetryActionDTO.none,
);
});
it('defaults to manual for non-Axios errors with no code', () => {
expect(resolveAssistantError(new Error('boom'), FALLBACK).retryAction).toBe(
RetryActionDTO.manual,
);
});
});
});

View File

@@ -1,91 +0,0 @@
import { AxiosError } from 'axios';
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
const FALLBACK = 'Something went wrong. Please try again.';
describe('resolveAssistantErrorMessage', () => {
it('returns backend message for a known error code', () => {
const err = new AxiosError('Request failed');
err.response = {
status: 400,
data: {
error: {
code: ErrorCodeDTO.thread_busy,
message: 'This thread is busy. Try again shortly.',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'This thread is busy. Try again shortly.',
isRateLimit: false,
});
});
it('falls back when error code is not in ErrorCodeDTO', () => {
const err = new AxiosError('Request failed');
err.response = {
status: 400,
data: {
error: {
code: 'future_unknown_code',
message: 'Backend-only message',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
isRateLimit: false,
});
});
it('marks HTTP 429 responses as rate limited', () => {
const err = new AxiosError('Too many requests');
err.response = {
status: 429,
data: {
error: {
code: ErrorCodeDTO.hourly_message_limit,
message: 'Hourly limit reached.',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'Hourly limit reached.',
isRateLimit: true,
});
});
it('uses backend message for known SSE rate-limit error codes', () => {
const err = Object.assign(new Error('Daily token limit exceeded.'), {
code: ErrorCodeDTO.daily_token_limit,
});
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: 'Daily token limit exceeded.',
isRateLimit: true,
});
});
it('marks 429 as rate limited even when error code is unknown', () => {
const err = new AxiosError('Too many requests');
err.response = {
status: 429,
data: {
error: {
code: 'future_unknown_code',
message: 'Too many requests',
},
},
} as AxiosError['response'];
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
message: FALLBACK,
isRateLimit: true,
});
});
});

View File

@@ -0,0 +1,209 @@
import { isAxiosError } from 'axios';
import {
ErrorCodeDTO,
RetryActionDTO,
type ErrorBodyDTO,
type ErrorResponseDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
export interface AssistantErrorResolution {
/** User-facing copy: code-specific FE copy → backend message → caller fallback. */
message: string;
/** Known backend error code, when one we recognise was supplied. */
code?: ErrorCodeDTO;
/**
* Whether/how the failed action may be retried:
* • `auto` — transient; the caller may silently re-attempt (capped).
* • `manual` — surface a Retry affordance to the user.
* • `none` — retrying would re-fail deterministically; offer nothing.
*/
retryAction: RetryActionDTO;
/** Quota/limit error — callers hide the retry + feedback bar (retrying just re-limits). */
isRateLimit: boolean;
}
/** Quota/limit codes — surfaced as rate-limit errors (no retry, feedback bar hidden). */
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
ErrorCodeDTO.thread_message_limit,
ErrorCodeDTO.connection_limit_exceeded,
ErrorCodeDTO.hourly_message_limit,
ErrorCodeDTO.daily_message_limit,
ErrorCodeDTO.daily_token_limit,
ErrorCodeDTO.daily_cost_limit,
ErrorCodeDTO.budget_exceeded,
]);
/**
* Codes whose retry would re-fail deterministically — permission/config/validation
* failures. These force `retryAction: none` regardless of HTTP status.
*/
const NON_RETRYABLE_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.permission_denied,
ErrorCodeDTO.user_disabled,
ErrorCodeDTO.org_disabled,
ErrorCodeDTO.validation_error,
ErrorCodeDTO.invalid_content_length,
ErrorCodeDTO.invalid_fork_target,
ErrorCodeDTO.missing_signoz_url,
ErrorCodeDTO.invalid_signoz_url,
ErrorCodeDTO.region_not_configured,
]);
/**
* Code-specific, user-friendly copy. Takes precedence over the backend's raw
* `error.message` so the user sees an actionable, consistent sentence rather
* than internal phrasing. Codes absent here fall through to the backend message.
*/
const ERROR_CODE_COPY: Partial<Record<ErrorCodeDTO, string>> = {
[ErrorCodeDTO.permission_denied]:
"You don't have permission to do that. Contact your workspace admin if you think this is a mistake.",
[ErrorCodeDTO.user_disabled]:
'Your access to the AI assistant has been disabled. Contact your workspace admin to re-enable it.',
[ErrorCodeDTO.org_disabled]:
'The AI assistant is disabled for your organisation. An admin can enable it in settings.',
[ErrorCodeDTO.thread_busy]:
'This conversation is still finishing a previous response. Give it a moment and try again.',
[ErrorCodeDTO.thread_has_active_execution]:
'This conversation is still finishing a previous response. Give it a moment and try again.',
[ErrorCodeDTO.hourly_message_limit]:
"You've reached the hourly message limit. Please try again later.",
[ErrorCodeDTO.daily_message_limit]:
"You've reached the daily message limit. Please try again tomorrow.",
[ErrorCodeDTO.daily_token_limit]:
"You've reached today's usage limit. Please try again tomorrow.",
[ErrorCodeDTO.daily_cost_limit]:
"You've reached today's usage limit. Please try again tomorrow.",
[ErrorCodeDTO.budget_exceeded]:
"You've reached your usage budget. Contact your workspace admin to raise it.",
[ErrorCodeDTO.thread_message_limit]:
'This conversation has reached its length limit. Start a new conversation to continue.',
[ErrorCodeDTO.connection_limit_exceeded]:
'Too many active conversations right now. Close one and try again.',
[ErrorCodeDTO.max_turns_exceeded]:
'The assistant reached the maximum number of steps for this request. Try rephrasing or breaking it into smaller asks.',
[ErrorCodeDTO.region_unreachable]:
"Couldn't reach your region's services. Please try again in a moment.",
[ErrorCodeDTO.region_not_configured]:
'No region is configured for the AI assistant yet. An admin can set this up in settings.',
[ErrorCodeDTO.mcp_unavailable]:
'A required service is temporarily unavailable. Please try again shortly.',
[ErrorCodeDTO.sandbox_unavailable]:
'The execution environment is temporarily unavailable. Please try again shortly.',
[ErrorCodeDTO.internal_error]:
'Something went wrong on our end. Please try again.',
};
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
return (
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
);
}
function isRetryActionDTO(value: unknown): value is RetryActionDTO {
return (
typeof value === 'string' &&
(Object.values(RetryActionDTO) as string[]).includes(value)
);
}
/**
* Pulls the structured error body out of either an Axios REST error or the
* SSE error the streaming loop throws (a plain `Error` augmented with `code`).
*/
function getErrorBody(err: unknown): ErrorBodyDTO | null {
if (isAxiosError(err)) {
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
}
const code = (err as { code?: string } | undefined)?.code;
const message = err instanceof Error ? err.message : undefined;
if (!code || !message) {
return null;
}
return { code: code as ErrorCodeDTO, message };
}
function isRateLimit(code: ErrorCodeDTO | undefined, err: unknown): boolean {
if (isAxiosError(err) && err.response?.status === 429) {
return true;
}
return code !== undefined && RATE_LIMIT_ERROR_CODES.has(code);
}
/**
* Resolves how the failed action may be retried. The backend's explicit signal
* (SSE `ErrorEventDTO.retryAction`) is authoritative; otherwise we derive it
* from the rate-limit/non-retryable code sets and the HTTP status.
*/
function resolveRetryAction(
err: unknown,
code: ErrorCodeDTO | undefined,
rateLimited: boolean,
): RetryActionDTO {
const explicit = (err as { retryAction?: unknown } | undefined)?.retryAction;
if (isRetryActionDTO(explicit)) {
return explicit;
}
if (rateLimited || (code !== undefined && NON_RETRYABLE_CODES.has(code))) {
return RetryActionDTO.none;
}
if (isAxiosError(err)) {
const status = err.response?.status;
// No response → network/timeout failure; retrying may well succeed.
if (status === undefined || status === 408) {
return RetryActionDTO.manual;
}
if (status === 401 || status === 403) {
return RetryActionDTO.none;
}
if (status === 409 || status >= 500) {
return RetryActionDTO.manual;
}
// Other 4xx (validation, bad request) re-fail deterministically.
return RetryActionDTO.none;
}
// Non-Axios transport/parse error with no code — let the user retry.
return RetryActionDTO.manual;
}
function resolveMessage(
code: ErrorCodeDTO | undefined,
body: ErrorBodyDTO | null,
fallback: string,
): string {
if (code !== undefined && ERROR_CODE_COPY[code]) {
return ERROR_CODE_COPY[code] as string;
}
// Trust the backend's message only for codes we recognise — never surface
// raw text for unknown codes (could be an internal stack trace).
if (code !== undefined && body?.message.trim()) {
return body.message.trim();
}
return fallback;
}
/**
* Single resolution point for both SSE and REST assistant errors. Maps the
* error onto user-facing copy plus retry semantics, degrading gracefully for
* unknown codes (falls back to `fallback` + a `manual` retry where sensible).
*/
export function resolveAssistantError(
err: unknown,
fallback: string,
): AssistantErrorResolution {
const body = getErrorBody(err);
const code = isErrorCodeDTO(body?.code) ? body?.code : undefined;
const rateLimited = isRateLimit(code, err);
return {
message: resolveMessage(code, body, fallback),
code,
retryAction: resolveRetryAction(err, code, rateLimited),
isRateLimit: rateLimited,
};
}

View File

@@ -1,71 +0,0 @@
import { isAxiosError } from 'axios';
import {
ErrorCodeDTO,
type ErrorBodyDTO,
type ErrorResponseDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
export interface AssistantErrorResolution {
message: string;
isRateLimit: boolean;
}
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
return (
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
);
}
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
ErrorCodeDTO.thread_message_limit,
ErrorCodeDTO.connection_limit_exceeded,
ErrorCodeDTO.hourly_message_limit,
ErrorCodeDTO.daily_message_limit,
ErrorCodeDTO.daily_token_limit,
ErrorCodeDTO.daily_cost_limit,
ErrorCodeDTO.budget_exceeded,
]);
function isRateLimitError(code: string | undefined, err: unknown): boolean {
if (isAxiosError(err) && err.response?.status === 429) {
return true;
}
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
}
function getErrorBody(err: unknown): ErrorBodyDTO | null {
if (isAxiosError(err)) {
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
}
const code = (err as { code?: string } | undefined)?.code;
const message = err instanceof Error ? err.message : undefined;
if (!code || !message) {
return null;
}
return { code: code as ErrorCodeDTO, message };
}
/**
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
* otherwise returns `fallback`.
*/
export function resolveAssistantErrorMessage(
err: unknown,
fallback: string,
): AssistantErrorResolution {
const body = getErrorBody(err);
const isRateLimit = isRateLimitError(body?.code, err);
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
return {
message: body.message.trim(),
isRateLimit,
};
}
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
}

View File

@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useExceptionsStripInfo } from '../useExceptionsStripInfo';
describe('useExceptionsStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '25 of 500 exceptions' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 42, totalCount: 42 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '42 of 42 exceptions',
});
});
it('says exception, not exceptions, when there is one', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 exception',
});
});
it('shows zero before the counts land', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 0, totalCount: 0 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 0 exceptions',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -38,6 +38,7 @@ import { Exception, PayloadProps } from 'types/api/errors/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FilterDropdownExtendsProps } from './types';
import { useExceptionsStripInfo } from './useExceptionsStripInfo';
import {
extractFilterValues,
getDefaultFilterValue,
@@ -160,6 +161,11 @@ function AllErrors(): JSX.Element {
},
]);
useExceptionsStripInfo({
shownCount: data?.payload?.length ?? 0,
totalCount: errorCountResponse.data?.payload ?? 0,
});
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
useEffect(() => {
setIsFetching(isFetching);

View File

@@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseExceptionsStripInfoArgs {
shownCount: number;
totalCount: number;
}
export function useExceptionsStripInfo({
shownCount,
totalCount,
}: UseExceptionsStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'exception')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -53,6 +53,10 @@
z-index: 0;
background: var(--l1-background);
// Column so the bottom strip sits under the scrolling content, not inside it.
display: flex;
flex-direction: column;
&.full-screen-content {
width: 100%;
}
@@ -70,7 +74,9 @@
.chat-support-gateway {
position: fixed;
bottom: 20px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
right: 20px;
z-index: 1000;

View File

@@ -35,7 +35,6 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
@@ -43,6 +42,7 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import BottomStrip from 'container/BottomStrip';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -51,6 +51,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -101,9 +103,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
trialInfo,
activeLicense,
isFetchingActiveLicense,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
userPreferences,
isFetchingUserPreferences,
updateChangelog,
@@ -402,6 +401,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -489,42 +489,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}
}, [isDarkMode]);
const showAddCreditCardModal = useMemo(() => {
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
let isPremiumSupportEnabled = false;
if (featureFlags && featureFlags.length > 0) {
isChatSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
?.active || false;
isPremiumSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
?.active || false;
}
return (
isLoggedIn &&
!isPremiumSupportEnabled &&
isChatSupportEnabled &&
!trialInfo?.trialConvertedToSubscription &&
isCloudUserVal
);
}
return false;
}, [
featureFlags,
featureFlagsFetchError,
isCloudUserVal,
isFetchingFeatureFlags,
isLoggedIn,
activeLicense,
trialInfo,
]);
const chatSupport = useChatSupport();
// Listen for API warnings
const handleWarning = (
@@ -868,6 +833,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (
@@ -878,12 +847,16 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
</Flex>
{showAddCreditCardModal && <ChatSupportGateway />}
{chatSupport === ChatSupportState.NeedsCard && !isSavedViewEnabled && (
<ChatSupportGateway />
)}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
<Toaster />
<Toaster
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
/>
</Layout>
</TooltipProvider>
);

View File

@@ -12,8 +12,12 @@ export const Layout = styled(LayoutComponent)`
}
`;
// Takes the height left in `.app-content` after the bottom strip.
// `min-height: 0` is not needed right now, overlayscrollbars already sets
// `overflow: auto` here. Kept so this does not break if that goes away.
export const LayoutContent = styled(LayoutComponent.Content)`
height: 100%;
flex: 1;
min-height: 0;
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -0,0 +1,36 @@
.askNoz {
display: flex;
align-items: center;
flex-shrink: 0;
}
.badge {
flex-shrink: 0;
display: inline-flex;
line-height: 0;
color: var(--accent-primary);
}
.pulseDot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
animation: askNozDotPulse 1.5s ease-in-out infinite;
transform: scale(0.8);
margin-right: -12px;
}
@keyframes askNozDotPulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.82);
}
}

View File

@@ -0,0 +1,76 @@
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import ROUTES from 'constants/routes';
import { Dot } from '@signozhq/icons';
import styles from './AskNoz.module.scss';
/**
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
* modal instead; this is deliberately the drawer.
*
* Carries the header's pending badge: when Noz is blocked on the user
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
* hiding the header button would remove a notification rather than move it.
*/
function AskNoz(): JSX.Element | null {
const { pathname } = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
const pendingUserInputCount = useAIAssistantStore(
selectPendingUserInputStreamCount,
);
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
return (
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
{showPendingBadge && (
<span className={styles.badge} aria-hidden>
<span className={styles.pulseDot}>
<Dot size={36} />
</span>
</span>
)}
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="ghost"
color="secondary"
size="sm"
className="noz-wave"
prefix={<Noz size={16} />}
onClick={(): void => openAIAssistant()}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
} your response`
: 'Ask Noz'
}
>
Ask Noz
</Button>
</TooltipSimple>
</div>
);
}
export default AskNoz;

View File

@@ -0,0 +1,133 @@
import { fireEvent } from '@testing-library/react';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
}));
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
typeof useIsAIAssistantEnabled
>;
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { useAIAssistantStore } = jest.requireMock(
'container/AIAssistant/store/useAIAssistantStore',
) as { useAIAssistantStore: jest.Mock };
/** The component reads the store through three separate selector calls. */
function mockStore({
isDrawerOpen = false,
isModalOpen = false,
pendingCount = 0,
}: {
isDrawerOpen?: boolean;
isModalOpen?: boolean;
pendingCount?: number;
} = {}): void {
const state = { isDrawerOpen, isModalOpen, streams: {} };
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
const picked = selector(state);
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
// so stand in the count we want to assert against.
return typeof picked === 'number' ? pendingCount : picked;
});
}
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
// wrapper and the button itself is reached by role — there is only ever one.
const SLOT = 'bottom-strip-ask-noz';
describe('AskNoz', () => {
beforeEach(() => {
jest.clearAllMocks();
mockEnabled.mockReturnValue(true);
mockStore();
});
describe('visibility', () => {
it('renders when the assistant is enabled', () => {
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toBeInTheDocument();
});
it('renders nothing when the assistant is disabled', () => {
mockEnabled.mockReturnValue(false);
const { queryByTestId } = render(<AskNoz />);
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
it('renders nothing while the drawer is open', () => {
mockStore({ isDrawerOpen: true });
const { queryByTestId } = render(<AskNoz />);
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
const { queryByTestId } = render(<AskNoz />, undefined, {
initialRoute: '/ai-assistant/some-conversation-id',
});
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
});
describe('what it does', () => {
it('opens the drawer on click', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(mockOpen).toHaveBeenCalledTimes(1);
});
});
describe('pending badge', () => {
it('announces the count when Noz is waiting on the user', () => {
mockStore({ pendingCount: 2 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute(
'aria-label',
'Ask Noz, 2 actions need your response',
);
});
it('uses the singular for one pending action', () => {
mockStore({ pendingCount: 1 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute(
'aria-label',
'Ask Noz, 1 action needs your response',
);
});
it('stays quiet when nothing is pending', () => {
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
});
it('stays quiet while the modal is open, Noz is already on screen', () => {
mockStore({ isModalOpen: true, pendingCount: 3 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
});
});
});

View File

@@ -0,0 +1,45 @@
.strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
flex-shrink: 0;
height: var(--bottom-strip-height);
padding: 0 var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
--button-font-size: 12px;
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
// cover the strip.
position: relative;
z-index: 1;
}
.left,
.right {
display: flex;
align-items: center;
gap: var(--spacing-4);
min-width: 0;
}
.left {
cursor: default;
}
// separator b/w strip entities
.left > *:not(:last-child)::after,
.right > *:not(:last-child)::after {
content: '';
flex-shrink: 0;
width: 1px;
height: 14px;
background: var(--l2-border);
margin-left: var(--spacing-4);
}

View File

@@ -0,0 +1,53 @@
import * as Sentry from '@sentry/react';
import { useAppContext } from 'providers/App/App';
import StripKeyValue from '../components/StripKeyValue/StripKeyValue';
import StripTypography from '../components/StripTypography/StripTypography';
import { useBottomStripStore } from '../store/useBottomStripStore';
import { type StripItem, StripItemKind } from '../types';
function renderItem(item: StripItem, index: number): JSX.Element {
if (item.kind === StripItemKind.KeyValue) {
return (
<StripKeyValue
key={index}
label={item.label}
value={item.value}
prefix={item.prefix}
tone={item.tone}
/>
);
}
return (
// eslint-disable-next-line react/no-array-index-key
<StripTypography key={index} prefix={item.prefix}>
{item.text}
</StripTypography>
);
}
function LeftSlot(): JSX.Element | null {
const { versionData } = useAppContext();
const left = useBottomStripStore((state) => state.left);
const ownerId = useBottomStripStore((state) => state.ownerId);
const version = versionData?.version?.trim();
const versionNode = version ? (
<StripTypography>{version}</StripTypography>
) : null;
if (!left?.length) {
return versionNode;
}
return (
// Keyed so a page whose items throw does not leave the boundary latched on
// the fallback for every page after it.
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
{left.map(renderItem)}
</Sentry.ErrorBoundary>
);
}
export default LeftSlot;

View File

@@ -0,0 +1,45 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
if (chatSupport === ChatSupportState.Unavailable) {
return null;
}
const handleClick = (): void => {
if (chatSupport === ChatSupportState.NeedsCard) {
setIsAddCreditCardModalOpen(true);
return;
}
window.Pylon?.('show');
};
return (
<>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<MessageSquareText size={16} />}
onClick={handleClick}
testId="bottom-strip-support"
>
Support
</Button>
<AddCreditCardModal
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
/>
</>
);
}
export default SupportButton;

View File

@@ -0,0 +1,81 @@
import { fireEvent } from '@testing-library/react';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import SupportButton from '../SupportButton';
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
}));
const mockChatSupport = useChatSupport as jest.MockedFunction<
typeof useChatSupport
>;
const BUTTON = 'bottom-strip-support';
const MODAL_TITLE = 'Add Credit Card for Chat Support';
describe('SupportButton', () => {
beforeEach(() => {
jest.clearAllMocks();
window.Pylon = jest.fn() as never;
});
describe('when Pylon is available', () => {
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
it('shows the button', () => {
const { getByTestId } = render(<SupportButton />);
expect(getByTestId(BUTTON)).toBeInTheDocument();
});
it('opens the Pylon widget on click', () => {
const { getByTestId } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
});
it('does not open the credit card modal', () => {
const { getByTestId, queryByText } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
});
});
describe('when the user needs a card', () => {
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
it('shows the same button', () => {
const { getByTestId } = render(<SupportButton />);
expect(getByTestId(BUTTON)).toBeInTheDocument();
});
it('opens the credit card modal on click, not Pylon', () => {
const { getByTestId, getByText } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
});
});
describe('when support is unavailable', () => {
beforeEach(() =>
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
);
it('renders nothing at all', () => {
const { queryByTestId } = render(<SupportButton />);
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,176 @@
import { useMemo, useState } from 'react';
import { fireEvent } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { Info } from 'types/api/v1/version/get';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
import { useBottomStripStore } from '../store/useBottomStripStore';
import { useBottomStrip } from '../useBottomStrip';
import { StripItemKind } from '../types';
/** Stands in for a page that puts something on the left of the strip. */
function Page({ text }: { text: string }): null {
useBottomStrip(useMemo(() => [{ kind: StripItemKind.Text, text }], [text]));
return null;
}
/**
* A page whose icon throws when the strip renders it. `prefix` is the one place
* the config still takes a node, so it is the only way a page can break it.
*/
function BoomPage(): null {
useBottomStrip([{ kind: StripItemKind.Text, text: 'boom', prefix: <Boom /> }]);
return null;
}
/** A page whose value changes without needing `rerender`. */
function ChangingPage(): JSX.Element {
const [count, setCount] = useState(600);
useBottomStrip(
useMemo(
() => [{ kind: StripItemKind.Text, text: `${count} traces` }],
[count],
),
);
return (
<button type="button" onClick={(): void => setCount(42)}>
change
</button>
);
}
/** A page node that blows up while the strip renders it. */
function Boom(): JSX.Element {
throw new Error('bad left node');
}
const VERSION = 'v0.134.67';
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
const withVersion = { appContextOverrides: { versionData } };
describe('BottomStrip', () => {
// The store is module level, so it outlives each test.
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
`${BOTTOM_STRIP_HEIGHT}px`,
);
unmount();
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
'',
);
});
// The string is whatever the Go build injected, so it is rendered untouched —
// same as SideNav. Release tags carry the "v", local builds do not.
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByText } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByText(version)).toBeInTheDocument();
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
const strip = getByTestId('bottom-strip');
expect(strip).toBeInTheDocument();
expect(strip).toHaveTextContent('');
});
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
// re-renders the original tree. These drive change through state and through
// separate trees instead, which the module-level store lets them share.
describe('left slot', () => {
it('shows what the page put there instead of the version', () => {
render(<Page text="600 traces" />);
const { getByText, queryByText } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByText('600 traces')).toBeInTheDocument();
expect(queryByText(VERSION)).not.toBeInTheDocument();
});
it('falls back to the version once the page is gone', () => {
const page = render(<Page text="600 traces" />);
const { getByText } = render(<BottomStrip />, undefined, withVersion);
page.unmount();
expect(getByText(VERSION)).toBeInTheDocument();
});
it('updates when the page changes what it shows', () => {
render(<ChangingPage />);
const { getByText, getByRole } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByText('600 traces')).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'change' }));
expect(getByText('42 traces')).toBeInTheDocument();
});
it('falls back to the version when the page node throws', () => {
// React logs the caught error, which is noise here.
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
render(<BoomPage />);
const { getByTestId, getByText } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(getByText(VERSION)).toBeInTheDocument();
consoleError.mockRestore();
});
it('keeps the new page value when the old page unmounts after it', () => {
// Navigation order: the next page mounts before the last one unmounts,
// so without the owner guard the outgoing page wipes the incoming value.
const pageA = render(<Page text="page A" />);
render(<Page text="page B" />);
const { getByText } = render(<BottomStrip />, undefined, withVersion);
pageA.unmount();
expect(getByText('page B')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,24 @@
// `:` is spaced like a word, per the Figma, so the gap here is a space width
// rather than the wider gap between whole items.
.keyValue {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--l2-foreground);
white-space: nowrap;
}
.prefix {
display: inline-flex;
align-items: center;
margin-right: var(--spacing-1);
}
.warning {
color: var(--warning);
}
.error {
color: var(--destructive);
}

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { StripTone } from '../../types';
import styles from './StripKeyValue.module.scss';
export interface StripKeyValueProps {
label: string;
value: string | number;
/** Leading icon. Same shape as `Button`'s. */
prefix?: ReactNode;
/** Tints the icon; the text stays neutral at every tone. */
tone?: StripTone;
}
/** A labelled number, rendered `Spans : 31`. */
function StripKeyValue({
label,
value,
prefix,
tone,
}: StripKeyValueProps): JSX.Element {
return (
<span className={styles.keyValue}>
{prefix && (
<span className={cx(styles.prefix, tone && styles[tone])} data-tone={tone}>
{prefix}
</span>
)}
<Typography.Text as="span" className={styles.label}>
{label}
</Typography.Text>
<Typography.Text as="span" className={styles.colon}>
:
</Typography.Text>
<Typography.Text as="span" className={styles.value}>
{value}
</Typography.Text>
</span>
);
}
StripKeyValue.defaultProps = { prefix: undefined, tone: StripTone.Default };
export default StripKeyValue;

View File

@@ -0,0 +1,46 @@
import { render } from 'tests/test-utils';
import { StripTone } from '../../../types';
import StripKeyValue from '../StripKeyValue';
describe('StripKeyValue', () => {
it('renders the label and the value around a colon it owns', () => {
const { getByText, container } = render(
<StripKeyValue label="Spans" value={31} />,
);
expect(getByText('Spans')).toBeInTheDocument();
expect(getByText('31')).toBeInTheDocument();
// The consumer never builds the string, so the spacing cannot drift.
expect(container.textContent).toBe('Spans:31');
});
it('tints only the icon when the tone is set', () => {
const { container } = render(
<StripKeyValue
label="Errors"
value={4}
tone={StripTone.Error}
prefix={<svg data-testid="icon" />}
/>,
);
// The tone lands on the icon, never on the text.
expect(container.querySelector('[data-tone]')).toHaveAttribute(
'data-tone',
'error',
);
});
it('leaves the icon untinted by default', () => {
const { container } = render(
<StripKeyValue label="Errors" value={0} prefix={<svg />} />,
);
expect(container.querySelector('[data-tone]')).toHaveAttribute(
'data-tone',
'default',
);
});
});

View File

@@ -0,0 +1,10 @@
.stripTypography {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import styles from './StripTypography.module.scss';
interface StripTypographyProps {
children: ReactNode;
/** Leading icon. Same shape as `Button`'s. */
prefix?: ReactNode;
}
/** A single phrase. A labelled number belongs in `StripKeyValue`. */
function StripTypography({
children,
prefix,
}: StripTypographyProps): JSX.Element {
return (
<span className={styles.stripTypography}>
{prefix}
<Typography.Text as="span">{children}</Typography.Text>
</span>
);
}
StripTypography.defaultProps = { prefix: undefined };
export type { StripTypographyProps };
export default StripTypography;

View File

@@ -0,0 +1,41 @@
import { useLayoutEffect } from 'react';
import AskNoz from './AskNoz/AskNoz';
import LeftSlot from './LeftSlot/LeftSlot';
import SupportButton from './SupportButton/SupportButton';
import styles from './BottomStrip.module.scss';
export const BOTTOM_STRIP_HEIGHT = 24;
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
BOTTOM_STRIP_HEIGHT_VAR,
`${BOTTOM_STRIP_HEIGHT}px`,
);
return (): void => {
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
};
}, []);
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
<LeftSlot />
</div>
<div className={styles.right}>
<AskNoz />
<SupportButton />
</div>
</div>
);
}
export default BottomStrip;

View File

@@ -0,0 +1,27 @@
import { create } from 'zustand';
import type { StripItem } from '../types';
interface BottomStripState {
/** What the strip shows on the left, or null to fall back to the version. */
left: StripItem[] | null;
/** Which page owns the current value — see `clearLeft`. */
ownerId: string | null;
setLeft: (ownerId: string, left: StripItem[] | null) => void;
clearLeft: (ownerId: string) => void;
}
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
left: null,
ownerId: null,
setLeft: (ownerId, left): void => set({ left, ownerId }),
// Only the current owner may clear. On a plain route swap React runs the old
// page's cleanup before the new page's effect, so this is moot, but two
// consumers can be mounted at once (a page under a drawer): the one that set
// last owns the slot, and the other unmounting must not wipe it.
clearLeft: (ownerId): void => {
if (get().ownerId === ownerId) {
set({ left: null, ownerId: null });
}
},
}));

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
export enum StripTone {
Default = 'default',
Warning = 'warning',
Error = 'error',
}
export enum StripItemKind {
/** A phrase in the page's own words, e.g. "18 services". */
Text = 'text',
/** A labelled number, rendered `label : value`. */
KeyValue = 'keyValue',
}
/** What a page may put on the left of the strip. */
export type StripItem =
| {
kind: StripItemKind.Text;
text: string;
prefix?: ReactNode;
}
| {
kind: StripItemKind.KeyValue;
/** Without a colon; the strip renders the separator. */
label: string;
value: string | number;
prefix?: ReactNode;
/** Tints the icon; the text stays neutral at every tone. */
tone?: StripTone;
};

View File

@@ -0,0 +1,25 @@
import { useEffect, useId } from 'react';
import { useBottomStripStore } from './store/useBottomStripStore';
import type { StripItem } from './types';
/**
* Puts `items` in the bottom strip for as long as the calling page is mounted.
* Pass null to show nothing and let the version through. The left is the only
* side a page contributes to; the right is the strip's own.
*
* There is no refresh API by design: a page that refetches re-renders, which
* produces new items, which re-runs this effect. Memoise the array where it is
* built, or the store is written on every render.
*/
export function useBottomStrip(items: StripItem[] | null): void {
const ownerId = useId();
const setLeft = useBottomStripStore((state) => state.setLeft);
const clearLeft = useBottomStripStore((state) => state.clearLeft);
useEffect(() => {
setLeft(ownerId, items);
return (): void => clearLeft(ownerId);
}, [items, ownerId, setLeft, clearLeft]);
}

View File

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

View File

@@ -1,6 +1,8 @@
.create-alert-v2-footer {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: 63px;
right: 0;
background-color: var(--l1-background);

View File

@@ -0,0 +1,109 @@
import { useState } from 'react';
import { Grid2X2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
EXPLORER_ACTION_EVENTS,
getExplorerActionEventPayload,
getExportPanelType,
} from './utils';
function AddToDashboardButton({
query,
sourcepage,
panelType,
isOneChartPerQuery = false,
}: {
query: Query | null;
sourcepage: DataSource;
panelType?: PANEL_TYPES;
isOneChartPerQuery?: boolean;
}): JSX.Element {
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
const { panelType: contextPanelType } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const open = (): void => {
if (!query) {
return;
}
void logEvent(
EXPLORER_ACTION_EVENTS[sourcepage].addToDashboard,
getExplorerActionEventPayload({
sourcepage,
panelType: contextPanelType,
isOneChartPerQuery,
}),
);
setQueryToExport(query);
};
const handleExport = (
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
): void => {
if (!dashboard || !queryToExport) {
return;
}
const exportPanelType = panelType ?? getExportPanelType(contextPanelType);
void logEvent(EXPLORER_ACTION_EVENTS[sourcepage].exported, {
...getExplorerActionEventPayload({
sourcepage,
panelType: contextPanelType,
isOneChartPerQuery,
}),
isNewDashboard,
dashboardName: dashboard.title,
});
const link = getExportToDashboardLink({
query: queryToExport,
panelType: exportPanelType,
dashboardId: dashboard.id,
widgetId: v4(),
});
if (link) {
safeNavigate(link);
}
};
const button = (
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={!query}
onClick={open}
prefix={<Grid2X2 size={16} />}
aria-label="Add to dashboard"
data-testid="explorer-add-to-dashboard"
/>
);
return (
<>
<TooltipSimple title="Add to dashboard">{button}</TooltipSimple>
<ExportPanelContainer
open={queryToExport !== null}
onClose={(): void => setQueryToExport(null)}
query={queryToExport}
onExport={handleExport}
/>
</>
);
}
export default AddToDashboardButton;

View File

@@ -0,0 +1,61 @@
import { useHistory } from 'react-router-dom';
import { ConciergeBell } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
EXPLORER_ACTION_EVENTS,
getCreateAlertLink,
getExplorerActionEventPayload,
} from './utils';
function CreateAlertButton({
query,
sourcepage,
isOneChartPerQuery = false,
}: {
query: Query | null;
sourcepage: DataSource;
isOneChartPerQuery?: boolean;
}): JSX.Element {
const history = useHistory();
const { panelType } = useQueryBuilder();
const createAlert = (): void => {
if (!query) {
return;
}
void logEvent(
EXPLORER_ACTION_EVENTS[sourcepage].createAlert,
getExplorerActionEventPayload({ sourcepage, panelType, isOneChartPerQuery }),
);
history.push(getCreateAlertLink({ query, panelType }));
};
const button = (
<Button
variant="ghost"
color="secondary"
size={isOneChartPerQuery ? 'icon' : 'md'}
disabled={!query}
onClick={createAlert}
prefix={<ConciergeBell size={16} />}
aria-label="Create an alert"
data-testid="explorer-create-alert"
>
{!isOneChartPerQuery && 'Create an alert'}
</Button>
);
return isOneChartPerQuery ? (
<TooltipSimple title="Create an alert">{button}</TooltipSimple>
) : (
button
);
}
export default CreateAlertButton;

View File

@@ -0,0 +1,39 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import AddToDashboardButton from './AddToDashboardButton';
import CreateAlertButton from './CreateAlertButton';
function ExplorerActions({
query,
dashboardQuery = query,
sourcepage,
panelType,
isOneChartPerQuery,
}: {
query: Query | null;
// When the dashboard export differs from the alert one (traces list injects columns).
dashboardQuery?: Query | null;
sourcepage: DataSource;
panelType?: PANEL_TYPES;
isOneChartPerQuery?: boolean;
}): JSX.Element {
return (
<>
<CreateAlertButton
query={query}
sourcepage={sourcepage}
isOneChartPerQuery={isOneChartPerQuery}
/>
<AddToDashboardButton
query={dashboardQuery}
sourcepage={sourcepage}
panelType={panelType}
isOneChartPerQuery={isOneChartPerQuery}
/>
</>
);
}
export default ExplorerActions;

View File

@@ -0,0 +1,320 @@
import logEvent from 'api/common/logEvent';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
getExportQueryData as getLogsExportQuery,
getQueryByPanelType as getLogsQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { OptionsQuery } from 'container/OptionsMenu/types';
import {
getExportQueryData as getTracesExportQuery,
getQueryByPanelType as getTracesQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import AddToDashboardButton from '../AddToDashboardButton';
import { getExportPanelType } from '../utils';
const DASHBOARD = { id: 'dash-1', title: 'Dash 1' };
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),
}));
jest.mock('uuid', () => ({ v4: (): string => 'widget-1' }));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
// The picker is the dialog's business; here it just hands a dashboard back.
jest.mock('container/ExportPanel/ExportPanelContainer', () => ({
__esModule: true,
default: ({
open,
query,
onExport,
}: {
open: boolean;
query: Query | null;
onExport: (dashboard: { id: string; title: string }) => void;
}): JSX.Element | null =>
open ? (
<button
type="button"
data-testid="export-stub"
data-query={JSON.stringify(query)}
onClick={(): void => onExport({ id: 'dash-1', title: 'Dash 1' })}
>
export
</button>
) : null,
}));
const mockSafeNavigate = jest.fn();
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedUseSafeNavigate = jest.mocked(useSafeNavigate);
const mockedLogEvent = jest.mocked(logEvent);
const FILTER = "service.name = 'frontend'";
const COLUMNS = [{ name: 'service.name' }, { name: 'name' }];
const options = { selectColumns: COLUMNS } as unknown as OptionsQuery;
function stagedQuery(dataSource: DataSource, queryName = 'A'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
id: `query-${queryName}`,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
queryName,
aggregateOperator: StringOperators.COUNT,
filter: { expression: FILTER },
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
function setPanelType(panelType: PANEL_TYPES): void {
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
typeof useQueryBuilder
>);
}
async function exportTo(
query: Query | null,
sourcepage: DataSource,
panelType: PANEL_TYPES,
panelTypeProp?: PANEL_TYPES,
): Promise<void> {
setPanelType(panelType);
render(
<AddToDashboardButton
query={query}
sourcepage={sourcepage}
panelType={panelTypeProp}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('explorer-add-to-dashboard'));
await user.click(screen.getByTestId('export-stub'));
}
function expectedLink(query: Query, panelType: PANEL_TYPES): string | null {
return buildExportPanelLink({
query,
panelType,
dashboardId: DASHBOARD.id,
});
}
describe('AddToDashboardButton', () => {
beforeEach(() => {
mockSafeNavigate.mockReset();
mockedLogEvent.mockClear();
mockedUseSafeNavigate.mockReturnValue({ safeNavigate: mockSafeNavigate });
});
it('is disabled without a query and the picker stays closed', () => {
setPanelType(PANEL_TYPES.LIST);
render(<AddToDashboardButton query={null} sourcepage={DataSource.LOGS} />);
expect(screen.getByTestId('explorer-add-to-dashboard')).toBeDisabled();
expect(screen.queryByTestId('export-stub')).not.toBeInTheDocument();
});
it('hands the picker the same query it will export', async () => {
const query = stagedQuery(DataSource.LOGS);
setPanelType(PANEL_TYPES.TIME_SERIES);
render(<AddToDashboardButton query={query} sourcepage={DataSource.LOGS} />);
await userEvent
.setup()
.click(screen.getByTestId('explorer-add-to-dashboard'));
expect(screen.getByTestId('export-stub')).toHaveAttribute(
'data-query',
JSON.stringify(query),
);
});
it("logs the explorer's own open and success events", async () => {
const query = stagedQuery(DataSource.TRACES);
await exportTo(query, DataSource.TRACES, PANEL_TYPES.TABLE);
expect(mockedLogEvent).toHaveBeenCalledWith(
'Traces Explorer: Add to dashboard clicked',
{
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TABLE,
},
);
expect(mockedLogEvent).toHaveBeenCalledWith(
'Traces Explorer: Add to dashboard successful',
{
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TABLE,
isNewDashboard: undefined,
dashboardName: DASHBOARD.title,
},
);
});
it('a panel type from the page wins over the fold of the context one', async () => {
const query = stagedQuery(DataSource.METRICS);
// context says list, the page says time series
await exportTo(
query,
DataSource.METRICS,
PANEL_TYPES.LIST,
PANEL_TYPES.TIME_SERIES,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(query, PANEL_TYPES.TIME_SERIES),
);
});
describe('logs, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.LOGS);
it('list: the list request shaping with timestamp desc, panel type list', async () => {
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
page: 1,
pageSize: 100,
filters: { items: [], op: 'AND' },
filter: { expression: FILTER },
});
const exportQuery = getLogsExportQuery(
listRequest,
PANEL_TYPES.LIST,
) as Query;
await exportTo(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'desc' },
]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.LIST),
);
});
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query untouched, same panel type',
async (panelType) => {
const exportQuery = getLogsExportQuery(staged, panelType) as Query;
await exportTo(exportQuery, DataSource.LOGS, panelType);
expect(exportQuery).toBe(staged);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(staged, panelType),
);
},
);
});
describe('traces, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.TRACES);
it('list: list shaping plus the selected columns, panel type list', async () => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, PANEL_TYPES.LIST),
getExportPanelType(PANEL_TYPES.LIST),
options,
);
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.LIST);
const [queryData] = exportQuery.builder.queryData;
expect(queryData.selectColumns).toStrictEqual(COLUMNS);
expect(queryData.groupBy).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.LIST),
);
});
it('trace: list shaping, no columns, panel type folds to time series', async () => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, PANEL_TYPES.TRACE),
getExportPanelType(PANEL_TYPES.TRACE),
options,
);
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.TRACE);
expect(exportQuery.builder.queryData[0].selectColumns).toBeUndefined();
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, PANEL_TYPES.TIME_SERIES),
);
});
// Same as the alert: the list / trace order lives in ListView state and the
// page shapes the export without it, so the panel query has no order by.
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: order by is not carried into the panel query',
async (panelType) => {
expect(staged.builder.queryData[0].orderBy).toHaveLength(1);
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, panelType),
getExportPanelType(panelType),
options,
);
await exportTo(exportQuery, DataSource.TRACES, panelType);
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([]);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(exportQuery, getExportPanelType(panelType)),
);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query untouched, same panel type',
async (panelType) => {
const exportQuery = getTracesExportQuery(
getTracesQueryByPanelType(staged, panelType),
getExportPanelType(panelType),
options,
);
await exportTo(exportQuery, DataSource.TRACES, panelType);
expect(exportQuery).toBe(staged);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(staged, panelType),
);
},
);
});
it('metrics: the chart query as is, panel type time series from the page', async () => {
const query = stagedQuery(DataSource.METRICS);
await exportTo(
query,
DataSource.METRICS,
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.TIME_SERIES,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
expectedLink(query, PANEL_TYPES.TIME_SERIES),
);
});
});

View File

@@ -0,0 +1,245 @@
import { useHistory } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
getExportQueryData as getLogsExportQuery,
getQueryByPanelType as getLogsQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import CreateAlertButton from '../CreateAlertButton';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
const mockPush = jest.fn();
const mockedUseHistory = jest.mocked(useHistory);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const mockedLogEvent = jest.mocked(logEvent);
const FILTER = "service.name = 'frontend'";
const ORDER_BY = [{ columnName: 'timestamp', order: 'asc' }];
function stagedQuery(
dataSource: DataSource,
aggregateOperator: StringOperators,
queryName = 'A',
): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
id: `query-${queryName}`,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
queryName,
aggregateOperator,
filter: { expression: FILTER },
orderBy: ORDER_BY,
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
function pushedQuery(): Query {
expect(mockPush).toHaveBeenCalledTimes(1);
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
expect(path).toBe(ROUTES.ALERTS_NEW);
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
}
function setPanelType(panelType: PANEL_TYPES): void {
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
typeof useQueryBuilder
>);
}
async function clickCreateAlert(
query: Query | null,
sourcepage: DataSource,
panelType: PANEL_TYPES,
): Promise<void> {
setPanelType(panelType);
render(<CreateAlertButton query={query} sourcepage={sourcepage} />);
await userEvent.setup().click(screen.getByTestId('explorer-create-alert'));
}
describe('CreateAlertButton', () => {
beforeEach(() => {
mockPush.mockReset();
mockedLogEvent.mockClear();
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
typeof useHistory
>);
});
it('is disabled and does nothing without a query', async () => {
await clickCreateAlert(null, DataSource.LOGS, PANEL_TYPES.LIST);
expect(screen.getByTestId('explorer-create-alert')).toBeDisabled();
expect(mockPush).not.toHaveBeenCalled();
});
it("logs the explorer's own create alert event", async () => {
const query = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
await clickCreateAlert(query, DataSource.TRACES, PANEL_TYPES.TIME_SERIES);
expect(mockedLogEvent).toHaveBeenCalledWith('Traces Explorer: Create alert', {
sourcepage: DataSource.TRACES,
panelType: PANEL_TYPES.TIME_SERIES,
});
});
it('one chart per query on metrics: icon only, and the event carries the layout', async () => {
setPanelType(PANEL_TYPES.TIME_SERIES);
render(
<CreateAlertButton
query={stagedQuery(DataSource.METRICS, StringOperators.COUNT)}
sourcepage={DataSource.METRICS}
isOneChartPerQuery
/>,
);
const button = screen.getByTestId('explorer-create-alert');
expect(button).not.toHaveTextContent('Create an alert');
await userEvent.setup().click(button);
expect(mockedLogEvent).toHaveBeenCalledWith(
'Metrics Explorer: Add to alert clicked',
{
sourcepage: DataSource.METRICS,
panelType: PANEL_TYPES.TIME_SERIES,
tab: 'explorer',
oneChartPerQueryEnabled: true,
},
);
});
describe('logs, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.LOGS, StringOperators.NOOP);
it('list: count aggregation, no order by, filter and pagination as the page sent them', async () => {
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
page: 1,
pageSize: 100,
filters: { items: [], op: 'AND' },
filter: { expression: FILTER },
});
const exportQuery = getLogsExportQuery(
listRequest,
PANEL_TYPES.LIST,
) as Query;
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
expect(queryData.groupBy).toStrictEqual([]);
expect(queryData.filter).toStrictEqual({ expression: FILTER });
expect(queryData.pageSize).toBe(100);
});
it('time series: staged query as is, order by and group by kept', async () => {
const tsStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
const exportQuery = getLogsExportQuery(
tsStaged,
PANEL_TYPES.TIME_SERIES,
) as Query;
await clickCreateAlert(
exportQuery,
DataSource.LOGS,
PANEL_TYPES.TIME_SERIES,
);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData).toStrictEqual(tsStaged.builder.queryData[0]);
});
it('table: staged query as is', async () => {
const tableStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
const exportQuery = getLogsExportQuery(
tableStaged,
PANEL_TYPES.TABLE,
) as Query;
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.TABLE);
expect(pushedQuery().builder).toStrictEqual(tableStaged.builder);
});
});
describe('traces, the query the page hands over per view', () => {
const staged = stagedQuery(DataSource.TRACES, StringOperators.NOOP);
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: count aggregation, group by cleared by the list shaping, filter kept',
async (panelType) => {
const exportQuery = getTracesQueryByPanelType(staged, panelType);
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
const [queryData] = pushedQuery().builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.groupBy).toStrictEqual([]);
expect(queryData.filter).toStrictEqual({ expression: FILTER });
},
);
// The list / trace views keep their order in ListView state, and the page
// shapes the export without it, so the alert never sees an order by.
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s: order by is not carried, even when the staged query has one',
async (panelType) => {
expect(staged.builder.queryData[0].orderBy).toStrictEqual(ORDER_BY);
const exportQuery = getTracesQueryByPanelType(staged, panelType);
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
expect(pushedQuery().builder.queryData[0].orderBy).toStrictEqual([]);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'%s: staged query as is',
async (panelType) => {
const aggStaged = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
const exportQuery = getTracesQueryByPanelType(aggStaged, panelType);
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
expect(pushedQuery().builder).toStrictEqual(aggStaged.builder);
},
);
});
it('metrics: the chart query as is', async () => {
const query = stagedQuery(DataSource.METRICS, StringOperators.COUNT);
await clickCreateAlert(query, DataSource.METRICS, PANEL_TYPES.TIME_SERIES);
expect(pushedQuery().builder).toStrictEqual(query.builder);
});
});

View File

@@ -0,0 +1,144 @@
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { StringOperators } from 'types/common/queryBuilder';
import { getCreateAlertLink, getExportPanelType } from '../utils';
function withFirstQuery(
base: Query,
overrides: Partial<Query['builder']['queryData'][number]>,
): Query {
return {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], ...overrides }],
},
};
}
function decodeQuery(link: string): Query {
const search = link.split('?')[1];
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
return JSON.parse(raw as string);
}
describe('getExportPanelType', () => {
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, PANEL_TYPES.LIST])(
'keeps %s',
(panelType) => {
expect(getExportPanelType(panelType)).toBe(panelType);
},
);
it.each([PANEL_TYPES.BAR, PANEL_TYPES.PIE, PANEL_TYPES.TRACE, null])(
'folds %s to time series',
(panelType) => {
expect(getExportPanelType(panelType)).toBe(PANEL_TYPES.TIME_SERIES);
},
);
});
describe('getCreateAlertLink', () => {
const orderBy = [{ columnName: 'timestamp', order: 'desc' }];
it('points at the new alert route with the query in the url', () => {
const query = initialQueriesMap.traces;
const link = getCreateAlertLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
});
expect(link.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
expect(decodeQuery(link)).toStrictEqual(query);
});
it('logs list: noop becomes count and order by is dropped', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const [queryData] = decodeQuery(
getCreateAlertLink({
query,
panelType: PANEL_TYPES.LIST,
}),
).builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
});
it('logs time series keeps order by', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.COUNT,
orderBy,
});
const [queryData] = decodeQuery(
getCreateAlertLink({
query,
panelType: PANEL_TYPES.TIME_SERIES,
}),
).builder.queryData;
expect(queryData.orderBy).toStrictEqual(orderBy);
});
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'%s drops order by whatever the source',
(panelType) => {
const query = withFirstQuery(initialQueriesMap.traces, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const [queryData] = decodeQuery(getCreateAlertLink({ query, panelType }))
.builder.queryData;
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
expect(queryData.orderBy).toStrictEqual([]);
},
);
it('converts a noop on any query, not only the first', () => {
const first = initialQueriesMap.logs.builder.queryData[0];
const query: Query = {
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{ ...first, aggregateOperator: StringOperators.COUNT },
{ ...first, queryName: 'B', aggregateOperator: StringOperators.NOOP },
],
},
};
const operators = decodeQuery(
getCreateAlertLink({ query, panelType: PANEL_TYPES.TIME_SERIES }),
).builder.queryData.map((item) => item.aggregateOperator);
expect(operators).toStrictEqual([
StringOperators.COUNT,
StringOperators.COUNT,
]);
});
it('does not mutate the query it is given', () => {
const query = withFirstQuery(initialQueriesMap.logs, {
aggregateOperator: StringOperators.NOOP,
orderBy,
});
const snapshot = JSON.stringify(query);
getCreateAlertLink({
query,
panelType: PANEL_TYPES.LIST,
});
expect(JSON.stringify(query)).toBe(snapshot);
});
});

View File

@@ -0,0 +1,85 @@
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
MetricsExplorerEventKeys,
MetricsExplorerEvents,
} from 'container/MetricsExplorer/events';
import { cloneDeep } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
export const EXPLORER_ACTION_EVENTS: Record<
DataSource,
{ createAlert: string; addToDashboard: string; exported: string }
> = {
[DataSource.LOGS]: {
createAlert: 'Logs Explorer: Create alert',
addToDashboard: 'Logs Explorer: Add to dashboard clicked',
exported: 'Logs Explorer: Add to dashboard successful',
},
[DataSource.TRACES]: {
createAlert: 'Traces Explorer: Create alert',
addToDashboard: 'Traces Explorer: Add to dashboard clicked',
exported: 'Traces Explorer: Add to dashboard successful',
},
[DataSource.METRICS]: {
createAlert: MetricsExplorerEvents.AddToAlertClicked,
addToDashboard: MetricsExplorerEvents.AddToDashboardClicked,
exported: MetricsExplorerEvents.AddToDashboardSuccessful,
},
};
export function getExplorerActionEventPayload({
sourcepage,
panelType,
isOneChartPerQuery,
}: {
sourcepage: DataSource;
panelType: PANEL_TYPES | null;
isOneChartPerQuery: boolean;
}): Record<string, unknown> {
if (sourcepage !== DataSource.METRICS) {
return { sourcepage, panelType };
}
return {
sourcepage,
panelType,
[MetricsExplorerEventKeys.Tab]: 'explorer',
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
};
}
export function getExportPanelType(panelType: PANEL_TYPES | null): PANEL_TYPES {
return panelType && AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
}
// Alerts need an aggregation, and list style views carry an order the alert
// cannot use.
export function getCreateAlertLink({
query,
panelType,
}: {
query: Query;
panelType: PANEL_TYPES | null;
}): string {
const isListStyle =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const alertQuery = cloneDeep(query);
alertQuery.builder.queryData = alertQuery.builder.queryData.map((item) => ({
...item,
aggregateOperator:
item.aggregateOperator === StringOperators.NOOP
? StringOperators.COUNT
: item.aggregateOperator,
orderBy: isListStyle ? [] : item.orderBy,
}));
return `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(alertQuery),
)}`;
}

View File

@@ -1,6 +1,8 @@
.explorer-options-container {
position: fixed;
bottom: 0px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;

View File

@@ -1,6 +1,8 @@
.explorer-option-droppable-container {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: -webkit-fill-available;
height: 24px;
display: flex;

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