Compare commits

..

14 Commits

Author SHA1 Message Date
Nityananda Gohain
c377424935 Merge branch 'main' into issue_6021 2026-09-09 08:08:59 +05:30
Srikanth Chekuri
d5af6f6d6b feat(telemetrymetadata): fields keys and values for the quick filters migration (#12794)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- `fields/values` returns `boolValues` for bool fields: static span
fields such as `has_error`, stored bool attributes (decided by a
row-existence check, so they take no limit slots), and keys the caller
declares bool. `isRoot` and `isEntryPoint` return `true` only, matching
the condition builder.
- `fields/values` honors `startUnixMilli` for logs and traces against
the tag tables. The predicate is `toDate(unix_milli / 1000) >=
toDate(start)`, the partition expression, because the tag tables
deduplicate a value per day and keep whichever hour was inserted last.
For traces this means "span started on or after the start's day", for
logs "ingested on or after the start's day". `endUnixMilli` is ignored.
- `fields/keys` honors `fieldContext` and `fieldDataType` for the static
intrinsic and calculated keys, matches them case-insensitively, types
`isRoot`/`isEntryPoint` as bool, gives meter keys a data type, filters
the traces keys table by tag type for the scope context, and no longer
returns early for the log and scope contexts (logs and audit).
- The span context keeps returning attribute keys, since queries spell
span attributes as `span.<name>`.
- Default quick filters name the span and log fields the way the fields
API serves them (`has_error` as a span bool, `name`, `duration_nano`,
`response_status_code`, `http_host`, `http_url`, `trace_id` as span
fields, `severity_text` as a log field). Migration 126 rewrites the
static fields of every stored quick filter the same way, whatever
context the legacy seeds gave them. Deprecated names such as `hasError`
are not served.
- The API-backed checkbox hook merges `boolValues` into its options; the
`has_error` title is kept.
- The fields/values handler no longer drops `boolValues` from the
response.


🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-08 22:11:27 +00:00
Vikrant Gupta
e7c48e964f fix(billing): keep spacing and link styling consistent when permissions are denied (#12801)
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

- The denied usage callout on the billing page keeps the same 8px gap to
the blocks below it that the usage table has, so the layout reads the
same with or without `subscription:read`.
- "pay the bill" in the failed-payment banner is an inline link again,
with a visible disabled state when the user lacks permission.

#### Additional Information

Part of SigNoz/platform-pod#3091.
2026-09-08 18:34:39 +00:00
Pandey
fe8c9d8cf4 chore: remove query progress tracking (#12784)
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

- The frontend websocket client for query progress
(`api/common/getQueryStats.ts`) was deleted in #9183, so
`/api/v3/query_progress` and `/ws/query_progress` have had no consumers
since.
- Removes the `query_progress` tracker package, the
`GetQueryProgressUpdates` handler, the `RegisterWebSocketPaths` route
group, and the `websocket.Upgrader` on `APIHandler`.
- Removes the clickhouse reader hooks that reported progress
(`ReportQueryStartForProgressTracking`, `SubscribeToQueryProgress`, and
the `clickhouse.WithProgress` callback in `GetTimeSeriesResultV3`),
along with the untyped `"queryId"` context value they relied on.
- Removes the `X-SIGNOZ-QUERY-ID` header the logs explorer still sent,
plus the now-dead `Sec-WebSocket-Protocol` entries in the CORS allowlist
and the `identn` tokenizer defaults. Livetail streams over SSE, so no
websocket endpoint remains.

#### Additional Information

- `gorilla/websocket` moves from a direct to an indirect dependency
(still pulled in by opamp-go).
- Dropping `Sec-WebSocket-Protocol` from `identn.tokenizer.headers`
changes a default in `conf/example.yaml`. It is only a token-extraction
source for websocket handshakes, of which there are none left, but
calling it out since it is a config-surface change.
2026-09-08 10:56:58 +00:00
dependabot[bot]
2ad3057fa6 chore(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1 in /scripts/promqltestcorpus (#12758)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.82.1 to 1.83.1.
<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.1</h2>
<h1>Security</h1>
<ul>
<li>xds/rbac: Fix a bug where nested <code>Principal</code> or
<code>Permission</code> rules with <code>:scheme</code> or
<code>grpc-</code> prefixed header matchers were not rejected, which
could cause DENY rules to fail open. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/nvxbug"><code>@​nvxbug</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where the <code>host</code> header matcher was
not being replaced with <code>:authority</code> in nested
<code>Principal</code> or <code>Permission</code> rules. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/nvxbug"><code>@​nvxbug</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a header matcher whose name was not
lowercase, such as <code>X-Role</code>, matched no header, which could
cause DENY rules to fail open. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a <code>:scheme</code> or
<code>grpc-</code> prefixed header matcher was accepted when its name
was not lowercase. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a <code>Host</code> header matcher was not
replaced with <code>:authority</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
</ul>
<h1>Performance</h1>
<ul>
<li>transport: Restrict memory overhead of buffering small data frames.
(<a
href="https://redirect.github.com/grpc/grpc-go/issues/9331">#9331</a>)</li>
</ul>
<h2>Release 1.83.0</h2>
<h1>Security</h1>
<ul>
<li>server: Stop reading from connections when flooded by HTTP/2 frames
to mitigate resource exhaustion. The default value for this limit is 100
frames, excluding DATA and HEADERS, and may be changed by setting
environment variable
<code>GRPC_GO_EXPERIMENTAL_CONTROL_BUFFER_THROTTLE_LIMIT</code>.</li>
<li>xds/rbac: Support <code>Metadata</code> and
<code>RequestedServerName</code> permissions matcher fields. If present
in a DENY rule, previously these would be ignored and fail-open.</li>
<li>xds/rbac: Fix panic when parsing unsupported fields in
<code>NotRule</code>/<code>NotId</code> permissions.</li>
<li>xds/rbac: Support the deprecated <code>source_ip</code> principal
identifier by treating it as equivalent to
<code>direct_remote_ip</code>.</li>
<li>xds: Fix panic when parsing route header matchers configured with
empty <code>exact_match</code>, <code>prefix_match</code>, or
<code>suffix_match</code> strings. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9223">#9223</a>)</li>
</ul>
<h1>New Features</h1>
<ul>
<li>xds/googlec2p: Enable DirectPath over Interconnect support for
on-premises clients via the <code>force-xds</code> target URI query
parameter. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9133">#9133</a>)</li>
<li>xds: Enable xDS configuration to control which fields get propagated
from ORCA backend metric reports to LRS load reports. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9145">#9145</a>)</li>
<li>authz: Add <code>OnPolicyUpdate</code> callback to
<code>FileWatcherOptions</code> to notify when an authz policy is loaded
or updated. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9142">#9142</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/hnefatl"><code>@​hnefatl</code></a></li>
</ul>
</li>
<li>xds: Add support for the GCP Authentication HTTP Filter, which
automatically fetches and attaches GCP Service Account Identity JWT
tokens to outgoing RPCs.
<ul>
<li>This feature can be enabled by setting environment variable
<code>GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER=true</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9119">#9119</a>)</li>
</ul>
</li>
<li>xds: Add support for xDS-based HTTP CONNECT proxies.
<ul>
<li>This feature can be enabled by setting environment variable
<code>GRPC_EXPERIMENTAL_XDS_HTTP_CONNECT=true</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9151">#9151</a>)</li>
</ul>
</li>
<li>xds: Add support for <code>contains_match</code> in route header
matchers. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9223">#9223</a>)</li>
</ul>
<h1>Bug Fixes</h1>
<ul>
<li>credentials/alts: Fix panic when processing malformed frames by
validating that the message frame length exceeds the message type field
size. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9197">#9197</a>)</li>
<li>grpc: Fix compilation on Plan 9 targets (<code>GOOS=plan9</code>),
broken since v1.81.0. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9255">#9255</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/Yusufihsangorgel"><code>@​Yusufihsangorgel</code></a></li>
</ul>
</li>
</ul>
<h2>Release 1.82.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>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1550d9e0cd"><code>1550d9e</code></a>
Change version to 1.83.1 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9336">#9336</a>)</li>
<li><a
href="ebba6f3f1b"><code>ebba6f3</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>
and <a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>
into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9335">#9335</a>)</li>
<li><a
href="8cfeca0e1e"><code>8cfeca0</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9331">#9331</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9333">#9333</a>)</li>
<li><a
href="dec6951305"><code>dec6951</code></a>
Change version to 1.83.1-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9229">#9229</a>)</li>
<li><a
href="4c226daff8"><code>4c226da</code></a>
Change version to 1.83.0 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9228">#9228</a>)</li>
<li><a
href="c198988aa9"><code>c198988</code></a>
Cherrypick 9223 into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9279">#9279</a>)</li>
<li><a
href="8ce3ebf24a"><code>8ce3ebf</code></a>
Cherrypick PR 9255 into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9263">#9263</a>)</li>
<li><a
href="e39384978c"><code>e393849</code></a>
Cherry-pick recent changes from master (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9240">#9240</a>)</li>
<li><a
href="2a112a82f5"><code>2a112a8</code></a>
authz: add onPolicyUpdate callback to authz file watcher (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9142">#9142</a>)</li>
<li><a
href="1a80fca960"><code>1a80fca</code></a>
vet: adds a check to disallow usage of regex.Compile in xDS code (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9216">#9216</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1">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-09-08 09:33:33 +00:00
Aditya Singh
9fc73fc0b1 feat(quick-filters): migrate keys + save/load to new field APIs (#12717)
#### Description

- quick filter keys now come from fields/keys for all pages (logs,
traces, exceptions, api monitoring, meter)..replaces the old v3
filter_suggestions and aggregate attribute_keys calls
- save/load moved to the new v2 /me/filters api that stores
TelemetryFieldKey (name, fieldContext, fieldDataType). removed the old
hand written v1 clients and types
- filters are now identified by name + fieldContext + fieldDataType so
same name fields with different context/datatype don't clash. kept this
contained to the quick filter settings
- values still use the old v3 path for now..that migration (checkboxv2 /
fields/values) is the next PR

#### Screen recording


https://github.com/user-attachments/assets/130c8054-9f7d-4d8d-b224-62da7ccaf41e



#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5979
2026-09-08 08:41:06 +00:00
nityanandagohain
bac667467f fix: minor changes 2026-09-08 12:35:52 +05:30
nityanandagohain
a79ecace96 fix: rule state history changes 2026-09-08 11:54:40 +05:30
Vikrant Gupta
3931c7163f chore(licensing): remove the legacy billing endpoints (#12771)
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

- Removes the legacy `/api/v1/checkout`, `/api/v1/billing`, and
`/api/v1/portal` routes and the licensing API layer that served them,
now that billing goes through the subscription endpoints.

#### Issues closed by this PR

Closes SigNoz/platform-pod#3091
2026-09-07 18:58:03 +00:00
Vikrant Gupta
fdfbf77d2b feat(billing): gate billing on subscription permissions (#12769)
#### Description

- Billing is now authorized through the `subscription` permissions
instead of the admin role, so custom roles can be granted billing
access.
- Denied users keep the page, tab, and controls in view; usage shows the
denied callout and checkout/portal actions are disabled with a tooltip.
- Billing calls the `/api/v1/subscriptions` endpoints added in #12767
through the generated client.

#### Additional Information

Part of SigNoz/platform-pod#3091. Stacked on #12767; retarget to `main`
once that merges.
2026-09-07 17:29:43 +00:00
Abhi kumar
56e7388677 fix(dashboards-v2): keep auto refresh running in full screen and the table page size sticky (#12776)
#### Description

- Auto refresh stopped in full screen: the timer lived inside the time
selector, which the full-screen branch unmounts along with the toolbar,
so the global time window never advanced and panels never refetched. The
timer now lives in a hook plus a headless `AutoRefreshTicker`, mounted
in the full-screen branch so exactly one timer runs in either mode.
- Table panel page size always snapped back to 10: the height-derived
page size was passed straight into antd's controlled
`pagination.pageSize` and `onChange` dropped the new size. A size picked
from the size changer now wins; panels that were never touched still
auto-fit to the panel height on resize.

Closes https://github.com/SigNoz/pulse-pod/issues/329
2026-09-07 16:29:46 +00:00
nityanandagohain
224671f84f feat: support ai trace alerts 2026-09-07 17:54:48 +05:30
Naman Verma
861380dc65 feat: add create v2 api for notification channels (#12638)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This PR adds one endpoint, `POST /api/v2/notification_channels`, whose
typed `{name, displayName, config:{kind, spec}}` body replaces v1's
pass-through Alertmanager receiver JSON. v1 routes are deliberately
untouched, so the diff is near-purely additive.
List/get/update/delete/test come in the next PR.

<!--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/296

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

Eventually references (rules, routing policies) migrate onto
internal_name, freeing name to become a user-editable display name. But
that will happen post rules migration so that all rules are on v2

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-07 11:36:12 +00:00
Gaurav Tewari
391f685e57 feat(llm-observability): AI explorer QB and builder_ai_query wiring (#12683)
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 the `builder_ai_query` envelope type, seeded by the AI explorer
via `initialQueryAIWithType`.
- AI queries pull filter-bar keys from
`/api/v1/ai_observability/fields/keys` and values from
`/api/v1/ai_observability/fields/values`.
- `QuerySection` drops the builder's custom order-by (`ExplorerOrderBy`
via `renderOrderBy`) for the list and trace panels, along with
`showTraceOperator`. Ordering for those views is the list's own concern,
not the builder's — the trace view supplies its own control in #12688.
`limit` and `having` are now rendered disabled on every panel instead of
hidden on list.
- Hides the span-scope selector for AI queries — "Root / Entrypoint
Spans" ANDs badly with the gate, since GenAI attributes sit on nested
spans. Derived from the query itself, so no new prop on the shared
builder.
- `createNewBuilderQuery` inherits `builderQueryType` from the first
query, the way it already inherits `source`.
- Response conversion reads aggregation metadata from both builder
envelope types, not just `builder_query`.

TDD -
https://app.notion.com/p/signoz/Query-Builder-Changes-3b4fcc6bcd1980889c7aed782665e78d

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=226748421&issue=SigNoz%7Cengineering-pod%7C5889

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/985cb178-ffb0-405c-b0a6-82ec242c3d68

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-07 07:29:31 +00:00
265 changed files with 8048 additions and 9341 deletions

View File

@@ -97,6 +97,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,

View File

@@ -397,7 +397,6 @@ identn:
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
enabled: true

View File

@@ -25,6 +25,379 @@ components:
- data
- orgId
type: object
AlertmanagertypesChannelConfig:
discriminator:
mapping:
email: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
googlechat: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
incidentio: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
jira: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
jsmops: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
msteams: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
properties:
kind:
enum:
- email
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelEmailConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig:
properties:
kind:
enum:
- googlechat
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelGoogleChatConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig:
properties:
kind:
enum:
- incidentio
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelIncidentIOConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig:
properties:
kind:
enum:
- jsmops
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelJSMOpsConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig:
properties:
kind:
enum:
- jira
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelJiraConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig:
properties:
kind:
enum:
- msteams
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelMSTeamsConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig:
properties:
kind:
enum:
- opsgenie
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelOpsgenieConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig:
properties:
kind:
enum:
- pagerduty
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelPagerdutyConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig:
properties:
kind:
enum:
- slack
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
properties:
kind:
enum:
- webhook
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelWebhookConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelEmailConfig:
properties:
headers:
additionalProperties:
type: string
type: object
html:
type: string
sendResolved:
nullable: true
type: boolean
to:
type: string
required:
- to
type: object
AlertmanagertypesChannelGoogleChatConfig:
properties:
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
webhookUrl:
type: string
required:
- webhookUrl
type: object
AlertmanagertypesChannelIncidentIOConfig:
properties:
description:
type: string
metadata:
additionalProperties:
type: string
type: object
sendResolved:
nullable: true
type: boolean
title:
type: string
token:
type: string
url:
type: string
required:
- url
- token
type: object
AlertmanagertypesChannelJSMOpsConfig:
properties:
apiKey:
type: string
description:
type: string
message:
type: string
priority:
type: string
sendResolved:
nullable: true
type: boolean
tags:
type: string
required:
- apiKey
type: object
AlertmanagertypesChannelJiraConfig:
properties:
apiToken:
type: string
customFields:
additionalProperties: {}
type: object
description:
type: string
email:
type: string
issueType:
type: string
labels:
items:
type: string
type: array
priority:
type: string
project:
type: string
reopenDuration:
type: string
reopenTransition:
type: string
resolveTransition:
type: string
sendResolved:
nullable: true
type: boolean
site:
type: string
summary:
type: string
wontFixResolution:
type: string
required:
- site
- project
- issueType
- email
- apiToken
type: object
AlertmanagertypesChannelKind:
enum:
- slack
- email
- webhook
- pagerduty
- opsgenie
- msteams
- googlechat
- jira
- jsmops
- incidentio
type: string
AlertmanagertypesChannelMSTeamsConfig:
properties:
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
webhookUrl:
type: string
required:
- webhookUrl
type: object
AlertmanagertypesChannelOpsgenieConfig:
properties:
apiKey:
type: string
apiUrl:
type: string
description:
type: string
details:
additionalProperties:
type: string
type: object
message:
type: string
priority:
type: string
sendResolved:
nullable: true
type: boolean
source:
type: string
required:
- apiKey
type: object
AlertmanagertypesChannelPagerdutyConfig:
properties:
class:
type: string
client:
type: string
clientUrl:
type: string
component:
type: string
description:
type: string
details:
additionalProperties:
type: string
type: object
group:
type: string
routingKey:
type: string
sendResolved:
nullable: true
type: boolean
severity:
type: string
source:
type: string
url:
type: string
required:
- routingKey
type: object
AlertmanagertypesChannelSlackConfig:
properties:
apiUrl:
type: string
channel:
type: string
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
type: string
password:
type: string
sendResolved:
nullable: true
type: boolean
url:
type: string
username:
type: string
required:
- url
type: object
AlertmanagertypesDeprecatedGettableAlert:
properties:
annotations:
@@ -54,6 +427,30 @@ components:
- rule
- policy
type: string
AlertmanagertypesGettableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
createdAt:
format: date-time
type: string
displayName:
type: string
id:
type: string
name:
type: string
updatedAt:
format: date-time
type: string
required:
- name
- displayName
- config
- id
- createdAt
- updatedAt
type: object
AlertmanagertypesGettableRoutePolicy:
properties:
channels:
@@ -356,6 +753,19 @@ components:
required:
- name
type: object
AlertmanagertypesPostableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
displayName:
type: string
generateName:
type: boolean
name:
type: string
required:
- config
type: object
AlertmanagertypesPostablePlannedMaintenance:
properties:
alertIds:
@@ -7939,6 +8349,8 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -7982,6 +8394,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8087,6 +8501,7 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:
@@ -19309,6 +19724,69 @@ paths:
summary: Get metrics treemap
tags:
- metrics
/api/v2/notification_channels:
post:
deprecated: false
description: This endpoint creates a notification channel
operationId: CreateNotificationChannel
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostableNotificationChannel'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
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
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:create
- tokenizer:
- notification-channel:create
summary: Create notification channel
tags:
- channels
/api/v2/orgs/me:
get:
deprecated: false

View File

@@ -1,85 +0,0 @@
package httplicensing
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type licensingAPI struct {
licensing licensing.Licensing
}
func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
return &licensingAPI{licensing: licensing}
}
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableSubscription)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(rw, err)
return
}
gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableSubscription)
}
func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableSubscription)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(rw, err)
return
}
gettableSubscription, err := api.licensing.Portal(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableSubscription)
}

View File

@@ -2,12 +2,9 @@ package httplicensing
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/tidwall/gjson"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -228,47 +225,6 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return nil
}
func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal checkout payload")
}
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal portal payload")
}
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error) {
license, err := provider.GetActive(ctx, organizationID)
if err != nil {

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
@@ -42,7 +41,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
IntegrationsController: opts.IntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)
@@ -72,10 +70,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -1,76 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/SigNoz/signoz/ee/query-service/model"
)
type DayWiseBreakdown struct {
Type string `json:"type"`
Breakdown []DayWiseData `json:"breakdown"`
}
type DayWiseData struct {
Timestamp int64 `json:"timestamp"`
Count float64 `json:"count"`
Size float64 `json:"size"`
UnitPrice float64 `json:"unitPrice"`
Quantity float64 `json:"quantity"`
Total float64 `json:"total"`
}
type tierBreakdown struct {
UnitPrice float64 `json:"unitPrice"`
Quantity float64 `json:"quantity"`
TierStart int64 `json:"tierStart"`
TierEnd int64 `json:"tierEnd"`
TierCost float64 `json:"tierCost"`
}
type usageResponse struct {
Type string `json:"type"`
Unit string `json:"unit"`
Tiers []tierBreakdown `json:"tiers"`
DayWiseBreakdown DayWiseBreakdown `json:"dayWiseBreakdown"`
}
type details struct {
Total float64 `json:"total"`
Breakdown []usageResponse `json:"breakdown"`
BaseFee float64 `json:"baseFee"`
BillTotal float64 `json:"billTotal"`
}
type billingData struct {
BillingPeriodStart int64 `json:"billingPeriodStart"`
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
Details details `json:"details"`
Discount float64 `json:"discount"`
SubscriptionStatus string `json:"subscriptionStatus"`
}
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
licenseKey := r.URL.Query().Get("licenseKey")
if licenseKey == "" {
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
return
}
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
var billing billingData
if err := json.Unmarshal(data, &billing); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billing)
}

View File

@@ -184,7 +184,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterWebSocketPaths(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
@@ -197,7 +196,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
})
handler := c.Handler(r)

View File

@@ -2,8 +2,6 @@
// 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;
@@ -19,20 +17,13 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
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(),
};
};
const createMockUPlotInstance = (): MockUPlotInstance => ({
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 =>
@@ -62,16 +53,14 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
target: HTMLElement,
_target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance(target);
return createMockUPlotInstance();
}
// 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

@@ -169,12 +169,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
// Check for workspace blocked (trial expired)
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
pathname === ROUTES.SETTINGS ||
pathname === ROUTES.BILLING ||
(isAdmin &&
(pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.MY_SETTINGS));
if (
pathname !== ROUTES.WORKSPACE_LOCKED &&

View File

@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.MY_SETTINGS);
});
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
it('should allow VIEWER to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.BILLING,
appContext: {
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.BILLING);
});
it('should redirect VIEWER to workspace locked when trying to access org-settings', async () => {
@@ -819,7 +819,7 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should redirect EDITOR to workspace locked when trying to access settings', async () => {
it('should allow EDITOR to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should not redirect when already on workspace locked page', () => {
@@ -1626,6 +1626,7 @@ describe('PrivateRoute', () => {
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
deniedRoles: DENIED_ROLES,
},
BILLING: { path: ROUTES.BILLING, deniedRoles: DENIED_ROLES },
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface DayBreakdownEntry {
timestamp: number;
total: number;
quantity: number;
count: number;
size: number;
}
export interface TierEntry {
quantity: number;
unitPrice: number;
tierCost: number;
}
export interface BreakdownEntry {
type: string;
unit: string;
dayWiseBreakdown: {
breakdown: DayBreakdownEntry[];
};
tiers?: TierEntry[];
}
export interface UsageResponsePayloadProps {
billingPeriodStart: number;
billingPeriodEnd: number;
details: {
total: number;
baseFee: number;
breakdown: BreakdownEntry[];
billTotal: number;
};
discount: number;
subscriptionStatus?: string;
}
const getUsage = async (
licenseKey: string,
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getUsage;

View File

@@ -19,8 +19,10 @@ import type {
import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
@@ -647,3 +649,87 @@ export const useTestChannelDeprecated = <
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));
};
/**
* This endpoint creates a notification channel
* @summary Create notification channel
*/
export const createNotificationChannel = (
alertmanagertypesPostableNotificationChannelDTO?: BodyType<AlertmanagertypesPostableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateNotificationChannel201>({
url: `/api/v2/notification_channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostableNotificationChannelDTO,
signal,
});
};
export const getCreateNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
> => {
const mutationKey = ['createNotificationChannel'];
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 createNotificationChannel>>,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> }
> = (props) => {
const { data } = props ?? {};
return createNotificationChannel(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createNotificationChannel>>
>;
export type CreateNotificationChannelMutationBody =
| BodyType<AlertmanagertypesPostableNotificationChannelDTO>
| undefined;
export type CreateNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create notification channel
*/
export const useCreateNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
> => {
return useMutation(getCreateNotificationChannelMutationOptions(options));
};

View File

@@ -37,6 +37,476 @@ export interface AlertmanagertypesChannelDTO {
updatedAt?: string;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type string
*/
apiUrl: string;
/**
* @type string
*/
channel?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
/**
* @enum slack
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind;
spec: AlertmanagertypesChannelSlackConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
email = 'email',
}
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
[key: string]: string;
};
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
* @type object
*/
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
/**
* @type string
*/
html?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
to: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO {
/**
* @enum email
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind;
spec: AlertmanagertypesChannelEmailConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind {
webhook = 'webhook',
}
export interface AlertmanagertypesChannelWebhookConfigDTO {
/**
* @type string
*/
bearerToken?: string;
/**
* @type string
*/
password?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
url: string;
/**
* @type string
*/
username?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO {
/**
* @enum webhook
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind;
spec: AlertmanagertypesChannelWebhookConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
pagerduty = 'pagerduty',
}
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
[key: string]: string;
};
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @type string
*/
class?: string;
/**
* @type string
*/
client?: string;
/**
* @type string
*/
clientUrl?: string;
/**
* @type string
*/
component?: string;
/**
* @type string
*/
description?: string;
/**
* @type object
*/
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
/**
* @type string
*/
group?: string;
/**
* @type string
*/
routingKey: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
severity?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
*/
url?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @enum pagerduty
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind;
spec: AlertmanagertypesChannelPagerdutyConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
opsgenie = 'opsgenie',
}
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
[key: string]: string;
};
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @type string
*/
apiKey: string;
/**
* @type string
*/
apiUrl?: string;
/**
* @type string
*/
description?: string;
/**
* @type object
*/
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
source?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @enum opsgenie
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind;
spec: AlertmanagertypesChannelOpsgenieConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind {
msteams = 'msteams',
}
export interface AlertmanagertypesChannelMSTeamsConfigDTO {
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
webhookUrl: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO {
/**
* @enum msteams
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind;
spec: AlertmanagertypesChannelMSTeamsConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind {
googlechat = 'googlechat',
}
export interface AlertmanagertypesChannelGoogleChatConfigDTO {
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
webhookUrl: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO {
/**
* @enum googlechat
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind;
spec: AlertmanagertypesChannelGoogleChatConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
[key: string]: unknown;
};
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @type string
*/
apiToken: string;
/**
* @type object
*/
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
email: string;
/**
* @type string
*/
issueType: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project: string;
/**
* @type string
*/
reopenDuration?: string;
/**
* @type string
*/
reopenTransition?: string;
/**
* @type string
*/
resolveTransition?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
site: string;
/**
* @type string
*/
summary?: string;
/**
* @type string
*/
wontFixResolution?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO {
/**
* @enum jira
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind;
spec: AlertmanagertypesChannelJiraConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind {
jsmops = 'jsmops',
}
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @type string
*/
apiKey: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
tags?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @enum jsmops
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind;
spec: AlertmanagertypesChannelJSMOpsConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
incidentio = 'incidentio',
}
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
[key: string]: string;
};
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @type string
*/
description?: string;
/**
* @type object
*/
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
token: string;
/**
* @type string
*/
url: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @enum incidentio
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind;
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
}
export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelKindDTO {
slack = 'slack',
email = 'email',
webhook = 'webhook',
pagerduty = 'pagerduty',
opsgenie = 'opsgenie',
msteams = 'msteams',
googlechat = 'googlechat',
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -88,6 +558,32 @@ export enum AlertmanagertypesExpressionKindDTO {
rule = 'rule',
policy = 'policy',
}
export interface AlertmanagertypesGettableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesGettableRoutePolicyDTO {
/**
* @type array,null
@@ -1748,6 +2244,22 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
wechat_configs?: ConfigWechatConfigDTO[];
};
export interface AlertmanagertypesPostableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
/**
* @type string
*/
displayName?: string;
/**
* @type boolean
*/
generateName?: boolean;
/**
* @type string
*/
name?: string;
}
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
@@ -9098,6 +9610,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9146,6 +9662,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9241,6 +9761,7 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',
@@ -12650,6 +13171,14 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await axios.get(`/orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -1,13 +0,0 @@
import axios from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const updateCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/checkout', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateCreditCardApi;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const manageCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/portal', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default manageCreditCardApi;

View File

@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
RawData,
ScalarData,
@@ -11,6 +13,11 @@ import {
} from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
const isBuilderQueryEnvelope = (
envelope: QueryEnvelope,
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
@@ -409,21 +416,19 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {
if (
query.type === 'builder_query' &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
(acc, query) => {
if (
isBuilderQueryEnvelope(query) &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).

View File

@@ -14,6 +14,7 @@ import {
QueryBuilderFormula as V5QueryBuilderFormula,
QueryEnvelope,
QueryRangePayloadV5,
RequestType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -935,3 +936,41 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
});
});
});
describe('convertBuilderQueriesToV5 builder query type', () => {
const buildEnvelope = (
builderQueryType: IBuilderQuery['builderQueryType'],
requestType: RequestType,
): QueryEnvelope => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.TRACES,
queryName: 'A',
builderQueryType,
} as unknown as IBuilderQuery,
},
requestType,
);
return envelope;
};
it.each<[RequestType]>([
['trace'],
['raw'],
['time_series'],
['scalar'],
['distribution'],
])('sends builder_ai_query for the %s request type', (requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_ai_query',
);
});
it.each<[string, IBuilderQuery['builderQueryType']]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('sends builder_query for %s', (_label, builderQueryType) => {
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
});
});

View File

@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
}
return {
type: 'builder_query' as QueryType,
type: queryData.builderQueryType ?? 'builder_query',
spec,
};
},

View File

@@ -4,11 +4,12 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
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 { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -18,9 +19,7 @@ export default function ChatSupportGateway(): JSX.Element {
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -38,7 +37,7 @@ export default function ChatSupportGateway(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -94,18 +93,23 @@ export default function ChatSupportGateway(): JSX.Element {
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<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">

View File

@@ -4,16 +4,17 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
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 { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -118,9 +119,7 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -138,7 +137,7 @@ function LaunchChatSupport({
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -193,18 +192,23 @@ function LaunchChatSupport({
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<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">

View File

@@ -16,8 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -54,6 +52,12 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -261,10 +265,8 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -317,8 +319,9 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -360,6 +363,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -493,10 +497,11 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -601,6 +606,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -0,0 +1,215 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -0,0 +1,111 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
const { dataSource } = query;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -34,6 +34,7 @@ export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
boolValues?: (boolean | null)[];
}): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
@@ -46,6 +47,7 @@ export function mockFieldsValuesAPI(response: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
boolValues: response.boolValues ?? [],
},
},
}),

View File

@@ -22,7 +22,10 @@ interface UseFieldValuesReturn {
isFetching: boolean;
}
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
@@ -89,8 +92,12 @@ export function useFieldValues({
values.numberValues
?.filter((value): value is number => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
const boolValues =
values.boolValues
?.filter((value): value is boolean => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues];
return [...stringValues, ...numberValues, ...boolValues];
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };

View File

@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
function SortableFilter({
filter,
@@ -25,13 +25,13 @@ function SortableFilter({
allowDrag,
allowRemove,
}: {
filter: FilterType;
onRemove: (filter: FilterType) => void;
filter: TelemetryFieldKey;
onRemove: (filter: TelemetryFieldKey) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filter.key });
useSortable({ id: filter.key as string });
const style = {
transform: CSS.Transform.toString(transform),
@@ -46,14 +46,14 @@ function SortableFilter({
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.key}
{filter.name}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter as FilterType);
onRemove(filter);
}}
>
Remove
@@ -69,8 +69,8 @@ function AddedFilters({
setAddedFilters,
}: {
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
@@ -90,12 +90,12 @@ function AddedFilters({
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: FilterType): void => {
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
@@ -116,7 +116,7 @@ function AddedFilters({
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key)}
items={addedFilters.map((f) => f.key as string)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>

View File

@@ -1,17 +1,17 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import {
FieldContext,
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -37,106 +37,48 @@ function OtherFilters({
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const isMeterDataSource = useMemo(
() => signal && signal === SignalType.METER_EXPLORER,
[signal],
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
);
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isLogDataSource,
},
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
}));
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
},
const addedKeys = new Set(
addedFilters.map((filter) =>
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: 'meter',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isMeterDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else if (isMeterDataSource) {
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
fieldKeysData?.data?.data?.keys || {},
)?.flat();
filterAttributes = fieldKeys.map(
(attr) =>
({
key: attr.name,
dataType: attr.fieldDataType,
type: attr.fieldContext,
signal: attr.signal,
}) as BaseAutocompleteData,
);
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [
suggestionsData,
aggregateKeysData,
addedFilters,
isLogDataSource,
fieldKeysData,
isMeterDataSource,
]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
type: filter.type,
},
]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);
};
const renderFilters = (): React.ReactNode => {
const isLoading =
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
if (isLoading) {
if (isFetching) {
return <OtherFiltersSkeleton />;
}
if (!otherFilters?.length) {
@@ -145,11 +87,11 @@ function OtherFilters({
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.key}</div>
<div className="qf-filter-key">{filter.name}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter as FilterType)}
onClick={(): void => handleAddFilter(filter)}
>
Add
</Button>

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
}): JSX.Element {
const {
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">

View File

@@ -1,27 +1,31 @@
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import { useCallback, useMemo, useState } from 'react';
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
import logEvent from 'api/common/logEvent';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
hasUnsavedChanges: boolean;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
() =>
customFilters.map((filter) => ({
...filter,
key: buildCompositeKey(
filter.name,
filter.fieldContext,
filter.fieldDataType,
),
})),
[customFilters],
);
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
normalizedCustomFilters,
);
const { notifications } = useNotifications();
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
useUpdateQuickFilters({
mutation: {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
void logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error) => {
notifications.error({
message: error.message || SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
},
});
const debouncedUpdate = useDebouncedFn((value) => {
@@ -78,19 +98,32 @@ const useQuickFilterSettings = ({
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
setAddedFilters(normalizedCustomFilters);
}, [normalizedCustomFilters, setAddedFilters]);
const hasUnsavedChanges = useMemo(
() =>
!(
addedFilters.length === normalizedCustomFilters.length &&
addedFilters.every(
(filter, index) => filter.key === normalizedCustomFilters[index].key,
)
),
[addedFilters, normalizedCustomFilters],
);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
pathParams: { source: signal },
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
key: filter.key,
datatype: filter.dataType,
type: filter.type,
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
})),
signal,
},
});
}
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
@@ -25,17 +23,16 @@ const useFilterConfig = ({
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data: customFilters = [],
data,
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
} = useGetQuickFilters(
{ source: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
[data],
);
const isDynamicFilters = useMemo(

View File

@@ -11,7 +11,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import '@testing-library/jest-dom';
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
);
});
it('keeps same-name fields with different context as distinct entries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
level: [
{
name: 'level',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'level',
fieldContext: 'span',
fieldDataType: 'string',
signal: 'logs',
},
],
},
},
}),
),
),
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
// Both `level` variants are shown despite sharing a name.
await waitFor(() =>
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
);
// Adding one variant removes only that one; the other stays.
const firstLevel = within(otherSection).getAllByText('level')[0];
const addButton = firstLevel.parentElement?.querySelector('button');
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
});
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -456,12 +513,10 @@ describe('Quick Filters with custom filters', () => {
});
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toStrictEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
expect(requestBody.filters).not.toContainEqual(
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
);
expect(requestBody.signal).toBe(SIGNAL);
expect(requestBody.filters).toHaveLength(10);
});
it('should render duration slider for duration_nono filter', async () => {
@@ -612,9 +667,9 @@ describe('Quick Filters refetch behavior', () => {
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
name: 'new.custom.filter',
fieldDataType: 'string',
fieldContext: 'resource',
} as const,
],
},

View File

@@ -1,17 +1,33 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
const FILTER_TITLE_MAP: Record<string, string> = {
duration_nano: 'Duration',
hasError: 'Has Error (Status)',
has_error: 'Has Error (Status)',
};
const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
// The map below exists only for the old v3 attribute-values fetch
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
// Once the values fetch moves to fields/values, remove this and reduce
// attributeKey to { id, key }.
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
[TelemetrytypesFieldContextDTO.resource]: 'resource',
};
const mapFieldContext = (fieldContext?: string): string =>
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
@@ -26,16 +42,16 @@ const getFilterName = (str: string): string => {
.join(' ');
};
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
if (FILTER_TYPE_MAP[att.name]) {
return FILTER_TYPE_MAP[att.name];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: FilterType[],
customFilters?: TelemetryFieldKey[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
@@ -46,13 +62,13 @@ export const getFilterConfig = (
(att, index) =>
({
type: getFilterType(att),
title: getFilterName(att.key),
title: getFilterName(att.name),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
id: att.name,
key: att.name,
dataType: fieldDataTypeToDataType(att.fieldDataType),
type: mapFieldContext(att.fieldContext),
},
defaultOpen: index < 2,
}) as IQuickFiltersConfig,

View File

@@ -4,14 +4,18 @@ import { refreshLicense } from 'api/generated/services/licenses';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { RefreshCcw } from '@signozhq/icons';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildLicenseUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
import { useAppContext } from 'providers/App/App';
function RefreshPaymentStatus({
type,
className,
withPortal,
}: {
type?: 'button' | 'text' | 'tooltip';
className?: string;
withPortal?: false;
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicense, activeLicenseRefetch } = useAppContext();
@@ -36,17 +40,25 @@ function RefreshPaymentStatus({
};
const button = (
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
<AuthZTooltip
checks={
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
}
enabled={!!activeLicense}
withPortal={withPortal}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</AuthZTooltip>
);
return (
@@ -62,6 +74,7 @@ function RefreshPaymentStatus({
RefreshPaymentStatus.defaultProps = {
type: 'button',
className: undefined,
withPortal: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
},
};
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
builder: {
...initialQueryWithType.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};
export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators),
number: Object.values(NumberOperators),

View File

@@ -15,7 +15,6 @@ export const REACT_QUERY_KEY = {
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS',
DASHBOARD_BY_ID: 'DASHBOARD_BY_ID',
GET_BILLING_USAGE: 'GET_BILLING_USAGE',
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',

View File

@@ -184,6 +184,11 @@
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
&[disabled] {
opacity: 0.6;
text-decoration: none;
}
}
.workspace-restricted-banner,

View File

@@ -20,7 +20,8 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import manageCreditCardApi from 'api/v1/portal/create';
import { updateSubscription } from 'api/generated/services/subscriptions';
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import getUserVersion from 'api/v1/version/get';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
@@ -30,6 +31,8 @@ import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
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';
@@ -63,8 +66,7 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
ChangelogSchema,
DeploymentType,
@@ -77,7 +79,6 @@ import {
} from 'types/api/licensesV3/getActive';
import { UserPreference } from 'types/api/preferences/preference';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
import { showErrorNotification } from 'utils/error';
import { eventEmitter } from 'utils/getEventEmitter';
@@ -166,9 +167,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
return Math.abs(currentDate.diff(userCreationDate, 'day'));
}, [user.createdAt]);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -186,7 +185,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
};
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -469,10 +468,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
if (user.role === USER_ROLES.ADMIN) {
history.push(ROUTES.BILLING);
}
}, [user.role]);
history.push(ROUTES.BILLING);
}, []);
const handleFailedPayment = useCallback((): void => {
manageCreditCard({
@@ -586,25 +583,21 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div>
Our systems are taking longer than expected for your trial workspace.
Please{' '}
{user.role === USER_ROLES.ADMIN ? (
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
logEvent(`Slow API Banner: Upgrade clicked`, {});
logEvent(`Slow API Banner: Upgrade clicked`, {});
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
) : (
'contact your administrator for upgrading to a paid plan for a smoother experience.'
)}
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
</div>
),
duration: 60000,
@@ -794,22 +787,18 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</span>
</div>
)}
@@ -826,22 +815,20 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<span>
{' '}
Please{' '}
<AuthZTooltip checks={SubscriptionManagePermissions}>
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</AuthZTooltip>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</span>
</div>
)}
</div>

View File

@@ -0,0 +1,113 @@
import {
SubscriptionCreatePermission,
SubscriptionReadPermission,
SubscriptionUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { trialConvertedToSubscriptionResponse } from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import BillingContainer from './BillingContainer';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('BillingContainer - AuthZ', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders usage and enables actions when all subscription permissions are granted', async () => {
server.use(setupAuthzAdmin());
render(<BillingContainer />);
await expect(
screen.findByRole('columnheader', { name: /data ingested/i }),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeEnabled();
});
expect(screen.queryByText(/not authorized/i)).not.toBeInTheDocument();
});
it('blocks the usage section when subscription read is denied', async () => {
server.use(setupAuthzDeny(SubscriptionReadPermission));
render(<BillingContainer />);
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('header-billing-button')).toBeInTheDocument();
expect(
screen.queryByRole('columnheader', { name: /data ingested/i }),
).not.toBeInTheDocument();
});
it('disables upgrade when subscription create is denied', async () => {
server.use(setupAuthzAllow(SubscriptionReadPermission));
render(<BillingContainer />);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.getByTestId('upgrade-plan-button')).toBeDisabled();
});
it('disables manage billing when subscription update is denied', async () => {
server.use(
setupAuthzAllow(SubscriptionReadPermission, SubscriptionCreatePermission),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.queryByTestId('upgrade-plan-button')).not.toBeInTheDocument();
});
it('disables manage billing when subscription list is denied', async () => {
server.use(
setupAuthzAllow(
SubscriptionReadPermission,
SubscriptionCreatePermission,
SubscriptionUpdatePermission,
),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
});
});

View File

@@ -4,7 +4,7 @@
margin: 0 auto var(--spacing-20);
.pageHeader {
margin-bottom: var(--spacing-8);
margin-bottom: var(--spacing-4);
.pageHeaderTitle {
font-weight: var(--label-medium-500-font-weight);
@@ -41,6 +41,8 @@
}
.pageInfo {
margin-bottom: var(--spacing-4);
:global(.ant-card) {
padding: var(--padding-3);
}
@@ -58,8 +60,12 @@
margin: var(--spacing-12) var(--spacing-4);
}
.usageDenied {
margin-bottom: var(--spacing-4);
}
.billingDetails {
margin: var(--spacing-12) 0;
margin: var(--spacing-4) 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
overflow: hidden;
@@ -128,7 +134,7 @@
}
.upgradePlanBenefits {
margin: 0 var(--spacing-4);
margin: 0;
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 var(--padding-12);
@@ -176,7 +182,7 @@
}
.billingUpdateNote {
margin-top: var(--spacing-8);
margin-top: var(--spacing-4);
font-family: var(--font-family-inter);
font-size: var(--font-size-sm);
font-style: normal;

View File

@@ -1,9 +1,11 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { billingSuccessResponse } from 'mocks-server/__mockdata__/billing';
import {
licensesSuccessResponse,
notOfTrailResponse,
trialConvertedToSubscriptionResponse,
} from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
import APIError from 'types/api/error';
import {
@@ -15,11 +17,6 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
__esModule: true,
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -31,14 +28,22 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
it('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = screen.getByRole('columnheader', {
const dataInjection = await screen.findByRole('columnheader', {
name: /data ingested/i,
});
expect(dataInjection).toBeInTheDocument();
const pricePerUnit = screen.getByRole('columnheader', {
const pricePerUnit = await screen.findByRole('columnheader', {
name: /price per unit/i,
});
expect(pricePerUnit).toBeInTheDocument();
@@ -49,13 +54,15 @@ describe('BillingContainer', () => {
const dayRemainingInBillingPeriod = await screen.findByText(
/Please upgrade plan now to retain your data./i,
{},
{ timeout: 5000 },
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
const upgradePlanButton = screen.getByTestId('upgrade-plan-button');
expect(upgradePlanButton).toBeInTheDocument();
const dollar = await screen.findByText(/\$1,278.3/i);
const dollar = await screen.findByText(/\$1,278.3/i, {}, { timeout: 5000 });
expect(dollar).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
@@ -86,7 +93,9 @@ describe('BillingContainer', () => {
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/\$0/i, {}, { timeout: 5000 }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(
@@ -132,7 +141,7 @@ describe('BillingContainer', () => {
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
const dollar0 = await screen.findByText(/\$0/i, {}, { timeout: 5000 });
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
@@ -250,7 +259,11 @@ describe('BillingContainer', () => {
billingSuccessResponse.data.billingPeriodStart,
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
const billingPeriod = await findByText(billingPeriodText);
const billingPeriod = await findByText(
billingPeriodText,
{},
{ timeout: 5000 },
);
expect(billingPeriod).toBeInTheDocument();
const currentBill = await screen.findByText('billing');

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import {
Card,
@@ -15,25 +15,36 @@ import {
TableColumnsType as ColumnsType,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import getUsage, {
BreakdownEntry,
UsageResponsePayloadProps,
} from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import type {
CreateSubscription201,
GetSubscription200,
SubscriptiontypesGettableSubscriptionUsageDTO,
SubscriptiontypesSubscriptionUsageBreakdownDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
createSubscription,
updateSubscription,
useGetSubscription,
} from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useAxiosError from 'hooks/useAxiosError';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty, pick } from 'lodash-es';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import {
SubscriptionCreatePermission,
SubscriptionManagePermissions,
SubscriptionReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useAppContext } from 'providers/App/App';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
@@ -135,7 +146,7 @@ export default function BillingContainer(): JSX.Element {
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<DataType[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<UsageResponsePayloadProps>
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
>({});
const {
@@ -146,7 +157,8 @@ export default function BillingContainer(): JSX.Element {
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { licenseKey } = useActiveLicenseKey();
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
useAuthZ([SubscriptionReadPermission]);
const { notifications } = useNotifications();
const handleError = useAxiosError();
@@ -154,33 +166,34 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
if (isEmpty(data?.payload)) {
(response: GetSubscription200): void => {
const usage = response?.data;
if (isEmpty(usage)) {
return;
}
const {
details: { breakdown = [], billTotal },
billingPeriodStart,
billingPeriodEnd,
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
const breakdown = usage.details?.breakdown ?? [];
const billTotal = usage.details?.billTotal ?? 0;
const billingPeriodStart = usage.billingPeriodStart ?? 0;
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
const formattedUsageData: DataType[] = [];
if (breakdown && Array.isArray(breakdown)) {
for (let index = 0; index < breakdown.length; index += 1) {
const element: BreakdownEntry = breakdown[index];
element?.tiers?.forEach((tier, i: number) => {
breakdown.forEach(
(
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
index: number,
) => {
element?.tiers?.forEach((tier, tierIndex: number) => {
formattedUsageData.push({
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
key: `${index}${tierIndex}`,
name: tierIndex === 0 ? (element?.type ?? '') : '',
unit: element?.unit ?? '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: String(tier.unitPrice),
cost: `$ ${tier.tierCost}`,
});
});
}
}
},
);
setData(formattedUsageData);
@@ -196,7 +209,7 @@ export default function BillingContainer(): JSX.Element {
setBillAmount(billTotal);
}
setApiResponse(data?.payload || {});
setApiResponse(usage);
},
[trialInfo?.onTrial],
);
@@ -208,11 +221,12 @@ export default function BillingContainer(): JSX.Element {
isLoading,
isFetching: isFetchingBillingData,
data: billingData,
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
queryFn: () => getUsage(licenseKey || ''),
onError: handleError,
enabled: !!licenseKey,
onSuccess: processUsageData,
} = useGetSubscription({
query: {
enabled: canReadSubscription || !!subscriptionAuthZError,
onError: handleError,
onSuccess: processUsageData,
},
});
useEffect(() => {
@@ -284,9 +298,7 @@ export default function BillingContainer(): JSX.Element {
/>
);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -303,7 +315,7 @@ export default function BillingContainer(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -313,7 +325,7 @@ export default function BillingContainer(): JSX.Element {
);
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -348,15 +360,21 @@ export default function BillingContainer(): JSX.Element {
updateCreditCard,
]);
const billingActionPermissions = trialInfo?.trialConvertedToSubscription
? SubscriptionManagePermissions
: [SubscriptionCreatePermission];
const subscriptionPastDueMessage = (): JSX.Element => (
<Typography>
{`We were not able to process payments for your account. Please update your card details `}
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
<AuthZTooltip checks={billingActionPermissions}>
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
</AuthZTooltip>
{` if your payment information has changed. Email us at `}
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
@@ -411,11 +429,7 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Flex>
<Card
bordered={false}
style={{ minHeight: 150, marginBottom: 16 }}
className={styles.pageInfo}
>
<Card bordered={false} className={styles.pageInfo}>
<Flex justify="space-between" align="center">
<Flex vertical gap={8}>
<p className={styles.pageInfoTitle}>
@@ -423,13 +437,14 @@ export default function BillingContainer(): JSX.Element {
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</p>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
<p className={styles.pageInfoSubtitle}>
{daysRemaining} {daysRemainingStr}
</p>
) : null}
</Flex>
<Button
<AuthZButton
checks={billingActionPermissions}
testId="header-billing-button"
variant="solid"
color="secondary"
@@ -443,7 +458,7 @@ export default function BillingContainer(): JSX.Element {
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
</AuthZButton>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -495,66 +510,81 @@ export default function BillingContainer(): JSX.Element {
))}
</Card>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
</div>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
<AuthZGuardContent
checks={[SubscriptionReadPermission]}
fallback={({ deniedPermissions }): JSX.Element => (
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
className={styles.usageDenied}
/>
)}
>
<>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus
type="button"
className={styles.billingFooterBtn}
/>
</div>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
</>
</AuthZGuardContent>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
@@ -597,7 +627,8 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
<AuthZButton
checks={[SubscriptionCreatePermission]}
testId="upgrade-plan-button"
variant="solid"
color="primary"
@@ -606,7 +637,7 @@ export default function BillingContainer(): JSX.Element {
onClick={handleBilling}
>
{t('upgrade_plan')}
</Button>
</AuthZButton>
</Col>
</Row>
</div>

View File

@@ -12,7 +12,7 @@ import {
} from 'lib/uPlotV2/components/types';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type uPlot from 'uplot';
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
@@ -25,7 +25,7 @@ import {
import styles from './BillingUsageGraph.module.scss';
interface BillingUsageGraphProps {
data: Partial<UsageResponsePayloadProps>;
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
billAmount: number;
}
@@ -55,7 +55,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
timestamp: (currentDay.timestamp ?? 0) + 86400,
count: 0,
size: 0,
quantity: 0,
@@ -94,7 +94,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
calculateStartEndTime(
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
),
[normalizedData],
);

View File

@@ -1,4 +1,4 @@
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -117,7 +117,9 @@ export function csvFileName(csvData: QuantityData[]): string {
return `billing_usage_(${startDate}-${endDate}).csv`;
}
export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
export function prepareCsvData(
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): {
csvData: string;
fileName: string;
} {
@@ -135,12 +137,14 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
}
export function calculateStartEndTime(
data: Partial<UsageResponsePayloadProps>,
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
timestamps.push(entry.timestamp);
if (typeof entry.timestamp === 'number') {
timestamps.push(entry.timestamp);
}
});
});

View File

@@ -6,7 +6,7 @@
border-radius: 4px;
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
margin: var(--spacing-4) 0 var(--spacing-12);
margin: var(--spacing-4) 0;
}
.info {

View File

@@ -1,3 +1,9 @@
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -36,10 +42,24 @@ function mockMailto(): {
}
describe('CancelSubscriptionBanner', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
jest.restoreAllMocks();
});
it('disables Cancel Subscription when subscription delete is denied', async () => {
server.use(setupAuthzDeny(SubscriptionDeletePermission));
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeDisabled();
});
});
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
@@ -56,9 +76,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
@@ -76,9 +97,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
@@ -95,9 +117,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
@@ -107,9 +130,10 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
@@ -119,9 +143,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -151,9 +176,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -172,9 +198,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -192,9 +219,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));

View File

@@ -11,6 +11,8 @@ import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
@@ -178,15 +180,17 @@ function CancelSubscriptionBanner(): JSX.Element {
immediately and removed from our servers.
</span>
</div>
<Button
<AuthZButton
checks={[SubscriptionDeletePermission]}
variant="solid"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
testId="cancel-subscription-btn"
>
Cancel Subscription
</Button>
</AuthZButton>
</div>
<DialogWrapper
open={dialogView !== null}

View File

@@ -11,17 +11,13 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import {
getExportQueryData,
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
@@ -52,6 +48,7 @@ import {
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
@@ -118,7 +115,7 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
initialQueryAIWithType,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
@@ -185,7 +182,7 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],

View File

@@ -17,12 +17,11 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import {
getTraceLink,
transformSpanRows,
@@ -43,6 +42,7 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
@@ -94,7 +94,7 @@ function ListView({
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);

View File

@@ -1,8 +1,6 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
@@ -10,33 +8,16 @@ import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
@@ -45,14 +26,10 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
showOnlyWhereClause={isListViewPanel}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -14,10 +14,9 @@ import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
@@ -31,6 +30,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import styles from './TracesView.module.scss';
@@ -60,7 +60,7 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
);

View File

@@ -0,0 +1,61 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
stagedQuery: Query,
orderBy?: string,
): Query => {
const query = stagedQuery
? cloneDeep(stagedQuery)
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
queryData.groupBy = [];
queryData.having = {
expression: '',
};
queryData.orderBy = orderByPayload;
}
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -108,7 +108,6 @@ function LogsExplorerViewsContainer({
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
const [queryId, setQueryId] = useState<string>(v4());
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
@@ -180,12 +179,7 @@ function LogsExplorerViewsContainer({
},
undefined,
listQueryKeyRef,
{
...(!isEmpty(queryId) &&
selectedPanelType !== PANEL_TYPES.LIST && {
'X-SIGNOZ-QUERY-ID': queryId,
}),
},
undefined,
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
'custom',
);
@@ -250,10 +244,6 @@ function LogsExplorerViewsContainer({
setRequestData(newRequestData);
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
useEffect(() => {
setQueryId(v4());
}, [data]);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {

View File

@@ -329,16 +329,17 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(8);
expect(result).toHaveLength(9);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'traces',
]);
});
@@ -419,16 +420,17 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(8);
expect(result).toHaveLength(9);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'traces',
]);
});

View File

@@ -6,6 +6,7 @@ import {
Gauge,
Key,
Logs,
Receipt,
Shield,
} from '@signozhq/icons';
@@ -69,6 +70,13 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type license ID, separate multiple with comma or space',
docsAnchor: 'license',
},
subscription: {
label: 'Subscription',
description: 'The workspace subscription, its usage and billing details.',
icon: Receipt,
selectorPlaceholder: 'Type * to cover the workspace subscription',
docsAnchor: 'subscription',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
@@ -107,7 +115,11 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
export const RESOURCE_ORDER = (
Object.keys(RESOURCE_PANELS) as AuthZResource[]
).sort((left, right) =>
RESOURCE_PANELS[left].label.localeCompare(RESOURCE_PANELS[right].label),
);
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
const panel = RESOURCE_PANELS[resource];

View File

@@ -0,0 +1,13 @@
import { useAutoRefreshSelection } from './useAutoRefreshSelection';
import { useAutoRefreshTick } from './useAutoRefreshTick';
/** Auto-refresh timer for views that hide the time selector that normally owns it. */
function AutoRefreshTicker(): null {
const { isEnabled, intervalMs } = useAutoRefreshSelection();
useAutoRefreshTick(isEnabled, intervalMs);
return null;
}
export default AutoRefreshTicker;

View File

@@ -0,0 +1,116 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { act, render, screen } from '@testing-library/react';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { AppState } from 'store/reducers';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../index';
const mockStore = configureStore<Partial<AppState>>([]);
const PATHNAME = '/dashboard/test-id';
const randomTime = 1700000000000000000;
function createGlobalTimeState(
overrides: Partial<GlobalReducer> = {},
): GlobalReducer {
return {
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
...overrides,
};
}
function renderAutoRefresh(
globalTime: GlobalReducer,
props: { disabled?: boolean } = {},
): MockStoreEnhanced<Partial<AppState>> {
const store = mockStore({ globalTime });
render(
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<AutoRefresh {...props} />
</Provider>
</MemoryRouter>,
);
return store;
}
function tickCount(store: MockStoreEnhanced<Partial<AppState>>): number {
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL)
.length;
}
describe('AutoRefresh', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders the trigger and ticks on the persisted interval', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(createGlobalTimeState());
expect(screen.getByTitle('Set auto refresh')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(15_000);
});
expect(tickCount(store)).toBe(3);
});
it('does not tick when auto refresh was never enabled for the route', () => {
const store = renderAutoRefresh(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
it('does not tick while the disabled prop is set', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(createGlobalTimeState(), { disabled: true });
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
it('renders nothing on a custom time range', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(
createGlobalTimeState({ selectedTime: 'custom' }),
);
expect(screen.queryByTitle('Set auto refresh')).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
});

View File

@@ -0,0 +1,162 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { act, render } from '@testing-library/react';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { AppState } from 'store/reducers';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../index';
import AutoRefreshTicker from '../AutoRefreshTicker';
const mockStore = configureStore<Partial<AppState>>([]);
const PATHNAME = '/dashboard/test-id';
const randomTime = 1700000000000000000;
function createGlobalTimeState(
overrides: Partial<GlobalReducer> = {},
): GlobalReducer {
return {
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
...overrides,
};
}
function renderTicker(
globalTime: GlobalReducer,
): MockStoreEnhanced<Partial<AppState>> {
const store = mockStore({ globalTime });
render(
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<AutoRefreshTicker />
</Provider>
</MemoryRouter>,
);
return store;
}
function timeIntervalActions(
store: MockStoreEnhanced<Partial<AppState>>,
): unknown[] {
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL);
}
describe('AutoRefreshTicker', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('advances the global time window on the interval persisted for the route', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(2);
});
it('does not tick when the route has no persisted interval', () => {
const store = renderTicker(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
it('does not tick while auto refresh is globally disabled', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(
createGlobalTimeState({ isAutoRefreshDisabled: true }),
);
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
it('does not tick on a custom time range', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(createGlobalTimeState({ selectedTime: 'custom' }));
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
});
// Mirrors DashboardContainer's swap: exactly one of the two must be ticking.
describe('AutoRefresh full screen handover', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps a single timer running across entering and leaving full screen', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = mockStore({ globalTime: createGlobalTimeState() });
function Harness({ active }: { active: boolean }): JSX.Element {
return active ? <AutoRefreshTicker /> : <AutoRefresh />;
}
const renderHarness = (active: boolean): JSX.Element => (
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<Harness active={active} />
</Provider>
</MemoryRouter>
);
const { rerender } = render(renderHarness(false));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(2);
rerender(renderHarness(true));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(4);
rerender(renderHarness(false));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(6);
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useInterval } from 'react-use';
import { Check, ChevronDown } from '@signozhq/icons';
import { Button, Popover } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
@@ -11,21 +10,18 @@ import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import useUrlQuery from 'hooks/useUrlQuery';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import _omit from 'lodash-es/omit';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
UPDATE_AUTO_REFRESH_INTERVAL,
UPDATE_TIME_INTERVAL,
} from 'types/actions/globalTime';
import { UPDATE_AUTO_REFRESH_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { refreshIntervalOptions } from './constants';
import { ButtonContainer } from './styles';
import { useAutoRefreshTick } from './useAutoRefreshTick';
import './AutoRefreshV2.styles.scss';
@@ -93,30 +89,10 @@ function AutoRefresh({
[selectedOption],
);
useInterval(() => {
const selectedValue = getOption?.value;
if (isDisabled || !isAutoRefreshEnabled) {
return;
}
if (selectedOption !== 'off' && selectedValue) {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
}
}, getOption?.value || 0);
useAutoRefreshTick(
!isDisabled && isAutoRefreshEnabled && selectedOption !== 'off',
getOption?.value || 0,
);
const onChangeHandler = useCallback(
(selectedValue: string) => {

View File

@@ -0,0 +1,29 @@
import { useLocation } from 'react-router-dom';
import get from 'api/browser/localstorage/get';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import { refreshIntervalOptions } from './constants';
export interface AutoRefreshSelection {
isEnabled: boolean;
intervalMs: number;
}
/**
* An entry for the current route means auto-refresh is on, its absence means off.
* Read on every render because localStorage isn't reactive.
*/
export function useAutoRefreshSelection(): AutoRefreshSelection {
const { pathname } = useLocation();
const selectedOption = JSON.parse(get(DASHBOARD_TIME_IN_DURATION) || '{}')[
pathname
];
return {
isEnabled: Boolean(selectedOption),
intervalMs:
refreshIntervalOptions.find((option) => option.key === selectedOption)
?.value || 0,
};
}

View File

@@ -0,0 +1,47 @@
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useInterval } from 'react-use';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Advances the global time window on the auto-refresh interval. The global
* "auto refresh disabled" flag and a custom range override the caller's `enabled`.
*/
export function useAutoRefreshTick(enabled: boolean, intervalMs: number): void {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const dispatch = useDispatch<Dispatch<AppActions>>();
const isTicking =
enabled &&
intervalMs > 0 &&
!globalTime.isAutoRefreshDisabled &&
globalTime.selectedTime !== 'custom';
useInterval(
() => {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
},
isTicking ? intervalMs : null,
);
}

View File

@@ -13,6 +13,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'role',
type: 'role',

View File

@@ -4,3 +4,5 @@ import type { BrandedPermission } from '../types';
// Resource-level — require a specific license id
export const buildLicenseReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `license:${id}`);
export const buildLicenseUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `license:${id}`);

View File

@@ -0,0 +1,26 @@
import { buildPermission } from '../utils';
export const SubscriptionReadPermission = buildPermission(
'read',
'subscription:*',
);
export const SubscriptionCreatePermission = buildPermission(
'create',
'subscription:*',
);
export const SubscriptionUpdatePermission = buildPermission(
'update',
'subscription:*',
);
export const SubscriptionListPermission = buildPermission(
'list',
'subscription:*',
);
export const SubscriptionDeletePermission = buildPermission(
'delete',
'subscription:*',
);
export const SubscriptionManagePermissions = [
SubscriptionListPermission,
SubscriptionUpdatePermission,
];

View File

@@ -1,75 +0,0 @@
.container {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 12px 8px;
box-sizing: border-box;
}
.label {
flex: 0 0 auto;
font-size: 11px;
line-height: 16px;
color: var(--text-vanilla-400);
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
.marker {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-1px);
background: var(--text-vanilla-100);
border-radius: 1px;
}
.caption {
flex: 0 0 auto;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-vanilla-400);
}
.keys {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.key {
display: flex;
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--text-vanilla-400);
}
.swatch,
.hatchSwatch {
width: 11px;
height: 11px;
border-radius: 2px;
border: 1px solid var(--l2-border);
box-sizing: border-box;
}
// Approximates the canvas hatch painted over null cells.
.hatchSwatch {
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,
var(--text-vanilla-400) 2px 3px
);
}

View File

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

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
import {
formatCount,
formatPercent,
HeatmapContributionRow,
} from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/** Only shown when the cell sums more than one group. */
export default function HeatmapContributionList({
rows,
groupByLabel,
}: {
rows: HeatmapContributionRow[];
/** The `groupBy` keys these rows are by. */
groupByLabel: string;
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
{groupByLabel && <span className={Styles.section}>{groupByLabel}</span>}
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="heatmap-tooltip-contribution-row"
>
<span
className={Styles.marker}
style={{ background: row.color }}
data-is-legend-marker={true}
/>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
</div>
))}
</div>
);
}

View File

@@ -1,151 +0,0 @@
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
// shadow (the plugin's portal wrapper is transparent and paints nothing).
//
// Padding lives on the sections rather than here, also matching the shared
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
// corner radius, so it has to reach the container edges.
.container {
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 220px;
&.pinned {
border-color: var(--ring);
}
}
// Separates the cell identity from whichever question the second block answers.
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.identity {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
font-size: 11px;
color: var(--text-vanilla-400);
font-variant-numeric: tabular-nums;
}
.filter {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
// Hollow ring, matching the legend's unselected marker — this names the filter the
// grid is under, it is not a colour key.
.filterMarker {
width: 9px;
height: 9px;
border-radius: 50%;
border: 2px solid currentColor;
flex-shrink: 0;
}
.filterLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-8);
}
.titleBucket {
font-size: 13px;
font-weight: 600;
color: var(--text-vanilla-100);
}
.titleCount {
font-size: 13px;
font-weight: 600;
color: var(--text-vanilla-100);
font-variant-numeric: tabular-nums;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
}
.section {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-vanilla-400);
padding: 0 var(--spacing-2) var(--spacing-1);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-1) var(--spacing-2);
border-radius: 3px;
font-size: 12px;
color: var(--text-vanilla-400);
font-variant-numeric: tabular-nums;
}
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
.rowHovered {
background: var(--l3-background);
color: var(--text-vanilla-100);
font-weight: 500;
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
text-align: right;
}
.rowPercent {
flex: 0 0 auto;
min-width: 40px;
text-align: right;
color: var(--text-vanilla-400);
opacity: 0.75;
}
.marker {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

View File

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

View File

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

View File

@@ -1,199 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
/** Rows shown either side of the hovered one. */
const NEIGHBOUR_SPAN = 2;
/** Below this share a percentage needs a decimal to stay informative. */
const PERCENT_DECIMAL_THRESHOLD = 10;
/** Below this, the header needs seconds to distinguish columns. */
const SUB_MINUTE_STEP = 60;
export const NO_DATA_LABEL = 'no data';
/**
* Which question the second block answers. A cell summed across several groups begs
* "which group?"; a cell that is already one series begs "how does this bucket
* compare with its neighbours?".
*/
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
label: string;
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Share of the cell's total, 0..100. */
percent: number;
}
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
// One enabled group contributes the whole cell, so there is nothing to break
// down — whether the query is ungrouped or the legend has isolated a group.
return visibleCount > 1
? HeatmapTooltipBody.Contribution
: HeatmapTooltipBody.Buckets;
}
/** A cell is an interval, so a single instant would misreport which observations
* it contains. The date is left to the x axis directly below. */
export function formatColumnRange({
start,
step,
timezone,
}: {
/** Column start, in seconds. */
start: number;
/** Column width, in seconds. */
step: number;
timezone: string;
}): string {
const format =
step < SUB_MINUTE_STEP
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.TIME;
const from = dayjs(start * 1000).tz(timezone);
const to = dayjs((start + step) * 1000).tz(timezone);
return `${from.format(format)}${to.format(format)}`;
}
/** Formatted with the panel's unit. */
export function formatBucketLabel({
yAxis,
row,
yAxisUnit,
decimalPrecision,
}: {
yAxis: HeatmapYAxis;
row: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): string {
const bucket = yAxis.rows[row];
if (!bucket) {
return '';
}
return formatRowLabel(bucket, (value) =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
);
}
export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
export function formatPercent(percent: number): string {
return percent >= PERCENT_DECIMAL_THRESHOLD
? `${Math.round(percent)}%`
: `${percent.toFixed(1)}%`;
}
/** Names the group the grid is currently isolated to. */
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
if (!series) {
return '';
}
if (!series.labels?.length) {
return series.label;
}
return series.labels
.map((label) => `${label.key} = ${label.value}`)
.join(', ');
}
/** The `groupBy` keys the breakdown is by. */
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
return keys.join(', ');
}
function formatSeriesValue(series: HeatmapSeries): string {
if (!series.labels?.length) {
return series.label;
}
return series.labels.map((label) => label.value).join(', ');
}
/** Highest first, so the list reads in the same direction as the y axis. */
export function buildBucketRows({
counts,
yAxis,
row,
column,
yAxisUnit,
decimalPrecision,
}: {
/** Row-major, as the renderer draws them. */
counts: Array<ArrayLike<number | null> | undefined>;
yAxis: HeatmapYAxis;
row: number;
column: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): HeatmapBucketRow[] {
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const rows: HeatmapBucketRow[] = [];
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
const index = row + offset;
const bucket = yAxis.rows[index];
if (!bucket) {
continue;
}
rows.push({
label: formatRowLabel(bucket, formatBucketValue),
count: counts[index]?.[column] ?? null,
isHovered: offset === 0,
});
}
return rows;
}
/**
* Largest first. Groups that contributed nothing are still listed — that is an
* answer, and dropping the row makes the list look truncated.
*/
export function buildContributionRows({
series,
timestamp,
row,
color,
}: {
/** Only the groups the legend has enabled — they are what the cell sums. */
series: HeatmapSeries[];
/** Column start, in seconds. */
timestamp: number;
row: number;
color: string;
}): HeatmapContributionRow[] {
const counts = series.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
// Absent or null contributed nothing to the sum, which is what this breaks down.
return point?.counts[row] ?? 0;
});
const total = counts.reduce((sum, count) => sum + count, 0);
return series
.map((entry, index) => ({
label: formatSeriesValue(entry),
color,
count: counts[index],
percent: total > 0 ? (counts[index] / total) * 100 : 0,
}))
.sort((a, b) => b.count - a.count);
}

View File

@@ -5,7 +5,6 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -104,21 +103,6 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
export interface HistogramTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {}
/** Not part of `TooltipProps`: it renders its own container, since none of its
* states is the flat series list the shared `Tooltip` draws. */
export interface HeatmapTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
/** Needed to break a summed cell down by contribution. */
series: HeatmapSeries[];
/** Groups the legend has enabled; the cell sums exactly these. */
visibleGroups: string[];
/** Same colour the legend and the densest cells use. */
groupColor: string;
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -148,7 +148,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
show = true,
side = 2, // bottom by default
space,
splits,
gap = 5, // default gap is 5
} = this.props;
@@ -180,9 +179,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (values) {
axisConfig.values = values;
}
if (splits) {
axisConfig.splits = splits;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}

View File

@@ -46,13 +46,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Special handling for time scales (X axis)
if (time) {
// An explicit range wins: the alignment below trims the tail of the window
// to whole minutes, which is right for point-based series but drops the
// final column of any chart whose marks span an interval.
if (range) {
return { [scaleKey]: { time: true, auto: false, range } };
}
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;

View File

@@ -78,8 +78,6 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */

View File

@@ -1,207 +0,0 @@
import {
clampColorSteps,
createHeatmapColorResolver,
DEFAULT_COLOR_STEPS,
DEFAULT_HEATMAP_COLORS,
getMaxCount,
MAX_COLOR_STEPS,
MIN_OPACITY_ALPHA,
normalizeCount,
resolveCountDomain,
} from '../colorScale';
import { HeatmapColorMode, HeatmapColorScale } from '../types';
const SERIES_COLOR = '#4e74f8';
describe('getMaxCount', () => {
it('ignores null cells', () => {
expect(
getMaxCount([
[1, null, 9],
[null, 4],
]),
).toBe(9);
});
it('returns 0 for an empty or all-null grid', () => {
expect(getMaxCount([])).toBe(0);
expect(getMaxCount([[null, null]])).toBe(0);
});
it('ignores non-finite counts', () => {
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
});
});
describe('resolveCountDomain', () => {
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
).toStrictEqual({
min: 0,
max: 20,
});
});
it('honours explicit clamps', () => {
expect(
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
).toStrictEqual({
min: 10,
max: 100,
});
});
it('collapses a max at or below min', () => {
expect(
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
).toStrictEqual({
min: 50,
max: 50,
});
});
});
describe('normalizeCount', () => {
const domain = { min: 0, max: 1000 };
it('spreads low counts on a log scale where a linear one washes them out', () => {
const log = (count: number): number =>
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
expect(log(10)).toBeCloseTo(1 / 3, 5);
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
expect(
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
).toBeCloseTo(0.01, 5);
});
it('puts 0 and 1 at the bottom of a log scale', () => {
expect(
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
expect(
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
});
it('reaches the top of the scale at max on every scale', () => {
[
HeatmapColorScale.Log,
HeatmapColorScale.Sqrt,
HeatmapColorScale.Linear,
].forEach((scale) => {
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
});
});
it('takes the square root of the linear position on a sqrt scale', () => {
expect(
normalizeCount({
count: 250,
domain: { min: 0, max: 1000 },
scale: HeatmapColorScale.Sqrt,
}),
).toBeCloseTo(0.5, 6);
});
it('clamps counts outside the domain', () => {
const scale = HeatmapColorScale.Linear;
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
});
it('returns the bottom of the scale when min equals max', () => {
expect(
normalizeCount({
count: 7,
domain: { min: 7, max: 7 },
scale: HeatmapColorScale.Log,
}),
).toBe(0);
});
it('handles a log domain whose min and max share a decade floor', () => {
expect(
normalizeCount({
count: 1,
domain: { min: 0, max: 1 },
scale: HeatmapColorScale.Log,
}),
).toBe(0);
});
});
describe('clampColorSteps', () => {
it('clamps to the supported range', () => {
expect(clampColorSteps(1)).toBe(2);
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
expect(clampColorSteps(32)).toBe(32);
});
it('falls back to the default for a non-finite value', () => {
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
});
});
describe('createHeatmapColorResolver', () => {
const build = (
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
isDarkMode = true,
): ReturnType<typeof createHeatmapColorResolver> =>
createHeatmapColorResolver({
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
domain: { min: 0, max: 1000 },
isDarkMode,
seriesColor: SERIES_COLOR,
});
it('leaves null cells uncoloured so they can be hatched', () => {
const resolver = build();
expect(resolver.colorFor(null)).toBeNull();
expect(resolver.positionOf(null)).toBeNull();
});
it('gives a zero count the bottom colour, not the null treatment', () => {
const resolver = build();
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
});
it('emits one ramp entry per step', () => {
expect(build({ steps: 8 }).ramp).toHaveLength(8);
});
it('maps the max count to the top of the ramp', () => {
const resolver = build({ steps: 8 });
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
});
it('picks different stops per theme so low counts stay near the surface', () => {
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
});
it('varies alpha in opacity mode, never below the visibility floor', () => {
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
// `color` drops the alpha channel from the string once it reaches 1.
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
});
it('prefers an explicit opacity fill over the series colour', () => {
const resolver = build({
mode: HeatmapColorMode.Opacity,
fill: '#e5484d',
steps: 2,
});
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
});
it('reports the domain it applied', () => {
expect(build().domain).toStrictEqual({ min: 0, max: 1000 });
});
});

View File

@@ -1,356 +0,0 @@
import {
canUseLogAxis,
decimateAxisSplits,
formatRowLabel,
resolveColumnIndex,
resolveHeatmapYAxis,
resolveRowIndex,
} from '../geometry';
import { HeatmapAxisScale } from '../types';
const BOUNDS = [128, 256, 1024, 4096];
describe('canUseLogAxis', () => {
it('accepts strictly positive bounds', () => {
expect(canUseLogAxis(BOUNDS)).toBe(true);
});
it('rejects a zero or negative bound', () => {
expect(canUseLogAxis([0, 128])).toBe(false);
expect(canUseLogAxis([-1, 128])).toBe(false);
});
it('rejects empty bounds', () => {
expect(canUseLogAxis([])).toBe(false);
});
});
describe('resolveHeatmapYAxis', () => {
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(rows).toHaveLength(BOUNDS.length + 1);
expect(rows[0]).toMatchObject({
upper: 128,
isUnderflow: true,
isOverflow: false,
});
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
expect(rows[4]).toMatchObject({
lower: 4096,
isOverflow: true,
isUnderflow: false,
});
});
it('exposes one edge per row boundary, ascending', () => {
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(edges).toHaveLength(rows.length + 1);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('places bounds in log space so row heights are log-proportional', () => {
const { splits, min, max } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
});
it('keeps bounds in value space on a linear axis', () => {
const { splits, min } = resolveHeatmapYAxis(
[10, 20, 30],
HeatmapAxisScale.Linear,
);
expect(splits).toStrictEqual([10, 20, 30]);
// Mean gap is 10, and the underflow edge never crosses zero.
expect(min).toBe(0);
});
it('sorts and de-duplicates bounds', () => {
const { rows, splits } = resolveHeatmapYAxis(
[256, 128, 256, Number.NaN],
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
expect(rows).toHaveLength(3);
});
it('gives a single bound an underflow and an overflow row', () => {
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
expect(rows).toHaveLength(2);
expect(rows[0].isUnderflow).toBe(true);
expect(rows[1].isOverflow).toBe(true);
expect(edges).toHaveLength(3);
});
it('degrades to an empty axis with no bounds', () => {
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
});
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
const { overflowSplit, edges } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
// A full row above the last boundary tick, so the two labels cannot collide.
expect(overflowSplit).toBe(edges[edges.length - 1]);
});
});
describe('resolveRowIndex', () => {
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
it('finds the row containing a value', () => {
expect(resolveRowIndex(edges, 200)).toBe(1);
expect(resolveRowIndex(edges, 2000)).toBe(3);
});
it('assigns a boundary to the row it opens', () => {
expect(resolveRowIndex(edges, 256)).toBe(2);
});
it('returns the last row on the top edge', () => {
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
edges.length - 2,
);
});
it('returns null outside the grid', () => {
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
});
it('returns null without at least one row', () => {
expect(resolveRowIndex([5], 5)).toBeNull();
});
});
describe('resolveColumnIndex', () => {
const timestamps = [100, 160, 220, 280];
const step = 60;
it('resolves by containment, not proximity', () => {
// 155 is nearer to 160, but the observations at 155 belong to column 0.
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
});
it('includes the column start and excludes its end', () => {
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
});
it('covers the trailing column using the step, not the next timestamp', () => {
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
});
it('returns null before the first column', () => {
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
});
it('returns null with no columns', () => {
expect(resolveColumnIndex([], 100, step)).toBeNull();
});
it('leaves the last column open when the step is unknown', () => {
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
});
});
describe('formatRowLabel', () => {
const format = (value: number): string => `${value}ms`;
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
it('labels the underflow row by its only real bound', () => {
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
});
it('labels the overflow row by its only real bound', () => {
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
});
it('labels an interior row as a range', () => {
expect(formatRowLabel(rows[1], format)).toBe('128ms 256ms');
});
});
describe('decimateAxisSplits', () => {
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const domain = { min: 0, max: 10 };
it('keeps every tick when they all fit', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
).toStrictEqual(splits);
});
it('thins to whatever fits at the available height', () => {
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
).toStrictEqual([0, 2, 4, 6, 8, 10]);
});
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 40,
minGapPx: 18,
});
expect(thinned[thinned.length - 1]).toBe(10);
});
it('returns ascending positions', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 60,
minGapPx: 18,
});
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
});
it('thins by pixel distance, not index, so uneven rows are handled', () => {
// Three boundaries bunched at the bottom of a wide linear domain: only the
// first and the far-away last are far enough apart to both get labels.
expect(
decimateAxisSplits({
splits: [1, 2, 3, 1000],
min: 0,
max: 1000,
plotHeight: 200,
minGapPx: 18,
}),
).toStrictEqual([3, 1000]);
});
it('leaves the tick set alone when it cannot measure', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
).toStrictEqual(splits);
expect(
decimateAxisSplits({
splits,
min: 5,
max: 5,
plotHeight: 400,
minGapPx: 18,
}),
).toStrictEqual(splits);
});
});
describe('resolveHeatmapYAxis — symmetric log', () => {
// The OTel SDK default explicit bucket boundaries, which start at zero.
const OTEL = [
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
];
// Clock skew in ms — a logs/traces field that straddles zero.
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
const PLOT_HEIGHT = 250;
/** Row heights in axis units, which map linearly to pixels. */
function rowHeights(bounds: number[]): number[] {
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
return edges.slice(1).map((edge, index) => edge - edges[index]);
}
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
const { edges } = resolveHeatmapYAxis(bounds, scale);
const span = edges[edges.length - 1] - edges[0];
const heights = edges
.slice(1)
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
return Math.min(...heights);
}
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
// A linear fallback would leave the boundaries untransformed.
expect(splits).not.toStrictEqual([0, 5, 10]);
});
it('gives every row a usable height for the OTel default boundaries', () => {
// Linear squeezes the 0100ms buckets — where the data is — under a pixel.
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
expect(shortestRowPx(OTEL, HeatmapAxisScale.Log)).toBeGreaterThan(4);
});
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
const heights = rowHeights(OTEL);
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
// One axis unit — the same space a decade gets above the threshold.
expect(heights[nearZero]).toBeCloseTo(1, 6);
});
it('places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('keeps negative boundaries ascending', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('round-trips a boundary back to its bucket value', () => {
const { splits, toBucketValue } = resolveHeatmapYAxis(
SKEW,
HeatmapAxisScale.Log,
);
expect(
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
).toStrictEqual(SKEW);
});
it('derives the linear threshold from the smallest non-zero boundary', () => {
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
const { edges, rows } = resolveHeatmapYAxis(
[-100, -10, 0, 10, 100],
HeatmapAxisScale.Log,
);
const crossing = rows.findIndex(
(row) => row.lower === -10 && row.upper === 0,
);
expect(edges[crossing]).toBeCloseTo(-1, 6);
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
});
it('leaves an all-positive layout on a plain log axis', () => {
const { splits } = resolveHeatmapYAxis(
[128, 256, 1024],
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
});
it('falls back to linear when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Log);
expect(splits).toStrictEqual([0]);
});
});

View File

@@ -1,176 +0,0 @@
import { resolveHeatmapGrid } from '../grid';
import { HeatmapSeries } from '../types';
const BUCKETS = [10, 20];
const STEP = 60;
/** Two groups over two columns, each missing a value the other reports. */
const TWO_GROUPS: HeatmapSeries[] = [
{
label: 'cart',
points: [
{ timestamp: 60, counts: [1, 2, 3] },
{ timestamp: 120, counts: [null, 5, 6] },
],
},
{
label: 'checkout',
points: [
{ timestamp: 60, counts: [10, 20, 30] },
{ timestamp: 120, counts: [40, null, 60] },
],
},
];
function resolve(
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
): ReturnType<typeof resolveHeatmapGrid> {
return resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: TWO_GROUPS,
...overrides,
});
}
describe('resolveHeatmapGrid', () => {
it('pivots per-timestamp count arrays into one row per bucket', () => {
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
// 2 boundaries describe 3 rows; each row spans both columns.
expect(counts).toStrictEqual([
[1, null],
[2, 5],
[3, 6],
]);
});
it('carries the bounds and step through untouched', () => {
const { bounds, step } = resolve();
expect(bounds).toStrictEqual(BUCKETS);
expect(step).toBe(STEP);
});
it('sums every group for the combined view', () => {
const { counts } = resolve();
expect(counts[0]).toStrictEqual([11, 40]);
expect(counts[2]).toStrictEqual([33, 66]);
});
it('keeps one group"s count where the other has no data', () => {
const { counts } = resolve();
// cart is null at 120 in row 0 while checkout reports 40.
expect(counts[0][1]).toBe(40);
// checkout is null at 120 in row 1 while cart reports 5.
expect(counts[1][1]).toBe(5);
});
it('reports a cell as no-data only when every group is missing it', () => {
const { counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
],
});
expect(counts).toStrictEqual([[null], [null]]);
});
it('distinguishes a zero count from no data', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
});
expect(counts[0][0]).toBe(0);
expect(counts[1][0]).toBeNull();
});
it('sums only the groups the legend has enabled', () => {
const { counts } = resolve({ visibleGroups: ['cart'] });
expect(counts[0]).toStrictEqual([1, null]);
expect(counts[2]).toStrictEqual([3, 6]);
});
it('sums every group when the legend passes nothing', () => {
const { counts } = resolve({ visibleGroups: undefined });
expect(counts[0]).toStrictEqual([11, 40]);
});
it('ignores an enabled label that left the result', () => {
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
expect(counts[0]).toStrictEqual([1, null]);
});
it('empties the grid when every group is excluded', () => {
const { timestamps, counts } = resolve({ visibleGroups: [] });
expect(timestamps).toStrictEqual([]);
expect(counts.every((row) => row.length === 0)).toBe(true);
});
it('unions timestamps when groups do not align', () => {
const { timestamps, counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
],
});
expect(timestamps).toStrictEqual([60, 180]);
expect(counts[0]).toStrictEqual([1, 3]);
});
it('sorts columns ascending regardless of response order', () => {
const { timestamps } = resolve({
buckets: [10],
series: [
{
label: 'a',
points: [
{ timestamp: 180, counts: [1, 2] },
{ timestamp: 60, counts: [3, 4] },
],
},
],
});
expect(timestamps).toStrictEqual([60, 180]);
});
it('pads rows the response left short', () => {
const { counts } = resolve({
buckets: [10, 20, 30],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
});
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
});
it('ignores counts beyond the bucket rows', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
});
expect(counts).toStrictEqual([[1], [2]]);
});
it('degrades to an empty grid with no buckets or no series', () => {
expect(resolve({ buckets: [] })).toStrictEqual({
bounds: [],
timestamps: [],
step: 0,
counts: [],
});
expect(resolve({ series: [] }).counts).toStrictEqual([]);
});
});

View File

@@ -1,289 +0,0 @@
import type uPlot from 'uplot';
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
import { resolveHeatmapYAxis } from '../geometry';
import { createHeatmapHooks } from '../heatmapPlugin';
import { HeatmapAxisScale, HeatmapCell } from '../types';
const BOUNDS = [100, 1000];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
const TIMESTAMPS = [1000, 1060, 1120];
const STEP = 60;
const PLOT_WIDTH = 300;
const PLOT_HEIGHT = 300;
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
const DATA = [
TIMESTAMPS,
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
] as unknown as uPlot.AlignedData;
interface FakeContext {
fillRect: jest.Mock;
fills: string[];
}
interface FakePlot {
plot: uPlot;
context: FakeContext;
setSeries: jest.Mock;
over: HTMLDivElement;
}
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
const over = document.createElement('div');
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
const fills: string[] = [];
const fillRect = jest.fn();
const context = { fills, fillRect };
const setSeries = jest.fn();
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
const ctx = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
setLineDash: jest.fn(),
createPattern: jest.fn(() => null),
set fillStyle(value: string) {
fills.push(value);
},
fillRect: (...args: number[]): void => {
fillRect(...args);
},
};
const plot = {
data: DATA,
cursor,
over,
setSeries,
ctx,
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
// x grows left to right; y is inverted, so the highest bucket is at the top.
valToPos: (value: number, scaleKey: string): number =>
scaleKey === 'x'
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
};
return { plot: plot as unknown as uPlot, context, setSeries, over };
}
function createHooks(
onHoverChange?: (cell: HeatmapCell | null) => void,
dimOnHover = true,
): ReturnType<typeof createHeatmapHooks> {
return createHeatmapHooks({
yAxis: Y_AXIS,
step: STEP,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
dimOnHover,
onHoverChange,
});
}
describe('heatmap renderer — lifecycle', () => {
it('mounts the hover overlay into the plot overlay and tears it down', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).not.toBeNull();
hooks.destroy(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).toBeNull();
});
});
describe('heatmap renderer — draw', () => {
it('paints every cell of every visible column', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
hooks.draw(plot);
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
// available under jsdom.
expect(context.fillRect).toHaveBeenCalledTimes(8);
});
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
const hooks = createHooks();
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = zeroed;
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).toHaveBeenCalledTimes(9);
expect(new Set(context.fills).size).toBe(1);
});
it('skips columns outside the current x range', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { scales: unknown }).scales = {
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
};
hooks.init(plot);
hooks.draw(plot);
// Only the first two columns overlap the range; the third starts past its end.
// 2 columns x 3 rows, less the null cell in column 1.
expect(context.fillRect).toHaveBeenCalledTimes(5);
});
it('draws nothing without columns', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = [[]];
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).not.toHaveBeenCalled();
});
});
describe('heatmap renderer — hover', () => {
it('focuses the hovered row and reports the cell under the cursor', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
// Left third of the plot is column 0; the top third is the overflow row.
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('reports a data gap as a null count instead of zero', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({
left: PLOT_WIDTH / 2,
top: PLOT_HEIGHT / 2,
});
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({
row: 1,
column: 1,
count: null,
});
});
it('does not re-report the same cell', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledTimes(1);
});
it('shows the overlay over the hovered cell and dims around it', () => {
const hooks = createHooks(undefined, true);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.display).toBe('block');
// Column 0 spans the left third of a 300px plot.
expect(overlay?.lastElementChild).toHaveStyle({
left: '0px',
width: '100px',
});
});
it('collapses the dim rects when dimming is off', () => {
const hooks = createHooks(undefined, false);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.firstElementChild).toHaveStyle({
width: '0px',
height: '0px',
});
});
it('releases focus and hides the overlay when the cursor leaves', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { cursor: { left: number; top: number } }).cursor = {
left: -10,
top: -10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
expect(
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
?.style.display,
).toBe('none');
});
it('clears the hover when the cursor is inside the plot but past the last column', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { data: unknown }).data = [[], [], [], []];
(plot as { cursor: { left: number; top: number } }).cursor = {
left: 10,
top: 10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
});
});

View File

@@ -1,75 +0,0 @@
import { getPaletteStops } from '../palettes';
import { HeatmapColorPalette } from '../types';
const ALL_PALETTES = Object.values(HeatmapColorPalette);
/** Perceived brightness, good enough to tell a ramp's ends apart. */
function luminance(hex: string): number {
const value = parseInt(hex.slice(1), 16);
// eslint-disable-next-line no-bitwise
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
describe('getPaletteStops', () => {
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
const stops = getPaletteStops(palette, true);
expect(stops).toHaveLength(9);
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
});
it.each(ALL_PALETTES)(
'%s climbs from dark to bright on a dark panel',
(palette) => {
const stops = getPaletteStops(palette, true);
// Low counts must sit near the surface, whichever direction the ramp is
// stored in — otherwise empty cells become the loudest thing on screen.
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
},
);
it.each(ALL_PALETTES)(
'%s falls from pale to saturated on a light panel',
(palette) => {
const stops = getPaletteStops(palette, false);
expect(luminance(stops[0])).toBeGreaterThan(
luminance(stops[stops.length - 1]),
);
},
);
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
// Only the polarity flips; the palette itself is theme-independent.
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
getPaletteStops(palette, true),
);
});
it('never mutates the stored ramp when reversing it', () => {
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
expect(first).toStrictEqual(second);
});
it('falls back to the first ramp for an unknown palette', () => {
const unknown = 'nope' as HeatmapColorPalette;
expect(getPaletteStops(unknown, true)).toStrictEqual(
getPaletteStops(HeatmapColorPalette.Ice, true),
);
});
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
// Every stop is a grey: red, green and blue channels stay equal.
stops.forEach((stop) => {
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
});
});
});

View File

@@ -1,198 +0,0 @@
import { Color as DesignToken } from '@signozhq/design-tokens';
import Color from 'color';
import { getPaletteStops } from './palettes';
import {
HeatmapColorMode,
HeatmapColorOptions,
HeatmapColorScale,
HeatmapColorPalette,
} from './types';
export const MIN_COLOR_STEPS = 2;
export const MAX_COLOR_STEPS = 128;
export const DEFAULT_COLOR_STEPS = 64;
/** Without a floor, the lowest counts read as "no data". */
export const MIN_OPACITY_ALPHA = 0.1;
/** Used when neither an explicit fill nor a series colour is available. */
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
mode: HeatmapColorMode.Palette,
scale: HeatmapColorScale.Log,
minCount: null,
maxCount: null,
palette: HeatmapColorPalette.Lava,
steps: DEFAULT_COLOR_STEPS,
fill: '',
};
export interface CountDomain {
min: number;
max: number;
}
/** Highest count, ignoring `null`. 0 for an empty grid. */
export function getMaxCount(counts: Array<Array<number | null>>): number {
let max = 0;
for (const row of counts) {
for (const count of row) {
if (count !== null && Number.isFinite(count) && count > max) {
max = count;
}
}
}
return max;
}
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
export function resolveCountDomain(
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
counts: Array<Array<number | null>>,
): CountDomain {
const min = options.minCount ?? 0;
const max = options.maxCount ?? getMaxCount(counts);
return max > min ? { min, max } : { min, max: min };
}
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
* all-zero grid renders at the bottom rather than disappearing. */
export function normalizeCount({
count,
domain,
scale,
}: {
count: number;
domain: CountDomain;
scale: HeatmapColorScale;
}): number {
const { min, max } = domain;
if (!(max > min)) {
return 0;
}
const clamped = Math.min(Math.max(count, min), max);
if (scale === HeatmapColorScale.Log) {
// 0 and 1 both sit at the bottom; log of either is meaningless.
const logMin = Math.log10(Math.max(min, 1));
const logMax = Math.log10(Math.max(max, 1));
if (!(logMax > logMin)) {
return 0;
}
return (Math.log10(Math.max(clamped, 1)) - logMin) / (logMax - logMin);
}
const linear = (clamped - min) / (max - min);
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
}
export function clampColorSteps(steps: number): number {
if (!Number.isFinite(steps)) {
return DEFAULT_COLOR_STEPS;
}
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
}
/** Colour at `t` (0..1) along a multi-stop ramp. */
function sampleStops(stops: string[], t: number): string {
if (stops.length === 0) {
return 'transparent';
}
if (stops.length === 1) {
return stops[0];
}
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
const lower = Math.min(Math.floor(scaled), stops.length - 2);
return Color(stops[lower])
.mix(Color(stops[lower + 1]), scaled - lower)
.hex();
}
/**
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
* fill at full strength. Depends only on the options, not on the data, so callers
* can read it before a grid exists.
*/
export function resolveExtremeColor({
options,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
isDarkMode: boolean;
seriesColor: string;
}): string {
if (options.mode === HeatmapColorMode.Opacity) {
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
}
const stops = getPaletteStops(options.palette, isDarkMode);
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
}
export interface HeatmapColorResolver {
/** `null` for a `null` count, which must be hatched. */
colorFor: (count: number | null) => string | null;
/** 0..1, or `null` for a `null` count. */
positionOf: (count: number | null) => number | null;
/** Low to high. The colour bar renders exactly these. */
ramp: string[];
domain: CountDomain;
}
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
* fill, so the grid matches its group's legend swatch. */
export function createHeatmapColorResolver({
options,
domain,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
domain: CountDomain;
isDarkMode: boolean;
/** Opacity-mode fill when `options.fill` is empty. */
seriesColor: string;
}): HeatmapColorResolver {
const steps = clampColorSteps(options.steps);
const positions = Array.from({ length: steps }, (_, index) =>
steps === 1 ? 0 : index / (steps - 1),
);
let ramp: string[];
if (options.mode === HeatmapColorMode.Opacity) {
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
ramp = positions.map((t) =>
base
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
.rgb()
.string(),
);
} else {
const stops = getPaletteStops(options.palette, isDarkMode);
ramp = positions.map((t) => sampleStops(stops, t));
}
const positionOf = (count: number | null): number | null => {
if (count === null || !Number.isFinite(count)) {
return null;
}
return normalizeCount({ count, domain, scale: options.scale });
};
return {
positionOf,
colorFor: (count): string | null => {
const t = positionOf(count);
if (t === null) {
return null;
}
const index = Math.min(Math.floor(t * steps), steps - 1);
return ramp[index];
},
ramp,
domain,
};
}

View File

@@ -1,297 +0,0 @@
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
const FALLBACK_LOG_RATIO = 2;
const EMPTY_Y_AXIS: HeatmapYAxis = {
rows: [],
edges: [],
splits: [],
overflowSplit: null,
toBucketValue: (axisValue: number): number => axisValue,
min: 0,
max: 1,
};
/** Ascending, finite, de-duplicated boundaries. */
function normalizeBounds(bounds: number[]): number[] {
const sorted = bounds
.filter((bound) => Number.isFinite(bound))
.sort((a, b) => a - b);
return sorted.filter(
(bound, index) => index === 0 || bound !== sorted[index - 1],
);
}
/** True when a plain log axis can place every boundary. */
export function canUseLogAxis(bounds: number[]): boolean {
return bounds.length > 0 && bounds.every((bound) => bound > 0);
}
interface AxisTransform {
toAxisValue: (value: number) => number;
toBucketValue: (axisValue: number) => number;
}
const LINEAR_TRANSFORM: AxisTransform = {
toAxisValue: (value) => value,
toBucketValue: (axisValue) => axisValue,
};
const LOG_TRANSFORM: AxisTransform = {
toAxisValue: (value) => Math.log10(value),
toBucketValue: (axisValue) => 10 ** axisValue,
};
/**
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
* bucket layout already declares it, so it never needs to be configured.
*/
function resolveLinearThreshold(bounds: number[]): number {
let threshold = Number.POSITIVE_INFINITY;
for (const bound of bounds) {
const magnitude = Math.abs(bound);
if (magnitude > 0 && magnitude < threshold) {
threshold = magnitude;
}
}
return Number.isFinite(threshold) ? threshold : 1;
}
/**
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
* deltas, balances — which a plain log cannot place at all, and which a linear axis
* squeezes into sub-pixel rows exactly where the interesting data sits.
*
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
* so it lands on a row edge, and row edges are already discrete.
*/
function createSymlogTransform(threshold: number): AxisTransform {
return {
toAxisValue: (value) =>
Math.abs(value) <= threshold
? value / threshold
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
toBucketValue: (axisValue) =>
Math.abs(axisValue) <= 1
? axisValue * threshold
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
};
}
function resolveAxisTransform(
bounds: number[],
scale: HeatmapAxisScale,
): AxisTransform {
if (scale !== HeatmapAxisScale.Log) {
return LINEAR_TRANSFORM;
}
if (canUseLogAxis(bounds)) {
return LOG_TRANSFORM;
}
// All-zero bounds have no magnitude to scale against.
if (!bounds.some((bound) => bound !== 0)) {
return LINEAR_TRANSFORM;
}
return createSymlogTransform(resolveLinearThreshold(bounds));
}
/**
* The open-ended rows still need a height, so each gets the grid's typical bucket
* width — the mean gap in axis space, which on a geometric layout is exactly one
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
*/
function resolveOuterEdges(
bounds: number[],
transform: AxisTransform,
isLinear: boolean,
): { lower: number; upper: number } {
const first = bounds[0];
const last = bounds[bounds.length - 1];
if (isLinear) {
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
// Never extend below zero unless the boundaries already do.
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
return { lower, upper: last + safeGap };
}
const axisFirst = transform.toAxisValue(first);
const axisLast = transform.toAxisValue(last);
const fallback = Math.log10(FALLBACK_LOG_RATIO);
const gap =
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
const safeGap = gap > 0 ? gap : fallback;
return {
lower: transform.toBucketValue(axisFirst - safeGap),
upper: transform.toBucketValue(axisLast + safeGap),
};
}
/** N boundaries produce N+1 rows: an underflow row below the first, and the
* `+Inf` overflow row above the last. */
export function resolveHeatmapYAxis(
bounds: number[],
scale: HeatmapAxisScale,
): HeatmapYAxis {
const normalized = normalizeBounds(bounds);
if (normalized.length === 0) {
return EMPTY_Y_AXIS;
}
const transform = resolveAxisTransform(normalized, scale);
const isLinear = transform === LINEAR_TRANSFORM;
const { toAxisValue, toBucketValue } = transform;
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
const last = normalized[normalized.length - 1];
const rows: HeatmapRow[] = [
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
];
for (let index = 1; index < normalized.length; index += 1) {
rows.push({
lower: normalized[index - 1],
upper: normalized[index],
isUnderflow: false,
isOverflow: false,
});
}
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
const edges = [
toAxisValue(lower),
...normalized.map(toAxisValue),
toAxisValue(upper),
];
return {
rows,
edges,
splits: normalized.map(toAxisValue),
overflowSplit: toAxisValue(upper),
toBucketValue,
min: edges[0],
max: edges[edges.length - 1],
};
}
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
export function resolveRowIndex(
edges: number[],
axisValue: number,
): number | null {
if (edges.length < 2) {
return null;
}
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
return null;
}
let low = 0;
let high = edges.length - 2;
while (low <= high) {
const mid = (low + high) >> 1;
if (axisValue < edges[mid]) {
high = mid - 1;
} else if (axisValue >= edges[mid + 1]) {
low = mid + 1;
} else {
return mid;
}
}
// Exactly on the top edge.
return edges.length - 2;
}
/**
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
* snaps to the closest boundary and would report the next column as soon as the
* cursor passed a cell's midpoint.
*/
export function resolveColumnIndex(
timestamps: ArrayLike<number>,
xValue: number,
step: number,
): number | null {
if (timestamps.length === 0) {
return null;
}
let low = 0;
let high = timestamps.length - 1;
let candidate = -1;
while (low <= high) {
const mid = (low + high) >> 1;
if (timestamps[mid] <= xValue) {
candidate = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
if (candidate < 0) {
return null;
}
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
return xValue < timestamps[candidate] + width ? candidate : null;
}
/** The open-ended rows are labelled by their one real boundary; the synthetic
* edge is a drawing device, not a value. */
export function formatRowLabel(
row: HeatmapRow,
formatValue: (value: number) => string,
): string {
if (row.isOverflow) {
return `> ${formatValue(row.lower)}`;
}
if (row.isUnderflow) {
return `${formatValue(row.upper)}`;
}
return `${formatValue(row.lower)} ${formatValue(row.upper)}`;
}
/**
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
* index, since linear rows are not the same height, and walks down from the top
* so the `∞` edge survives whatever else is dropped.
*/
export function decimateAxisSplits({
splits,
min,
max,
plotHeight,
minGapPx,
}: {
/** Candidates in axis space, ascending. */
splits: number[];
min: number;
max: number;
/** Plotting area height, in CSS pixels. */
plotHeight: number;
minGapPx: number;
}): number[] {
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
return splits;
}
const pixelsPerUnit = plotHeight / (max - min);
const kept: number[] = [];
let lastPosition = 0;
for (let index = splits.length - 1; index >= 0; index -= 1) {
// Axis values grow upward, pixel offsets downward.
const position = (max - splits[index]) * pixelsPerUnit;
if (kept.length === 0 || position - lastPosition >= minGapPx) {
kept.push(splits[index]);
lastPosition = position;
}
}
return kept.reverse();
}

View File

@@ -1,99 +0,0 @@
import { HeatmapGrid, HeatmapSeries } from './types';
const EMPTY_GRID: HeatmapGrid = {
bounds: [],
timestamps: [],
step: 0,
counts: [],
};
/**
* Highest single-cell count each group reaches. Read against the same domain the
* colour bar uses, this is where a group sits on that bar.
*/
export function resolveGroupPeaks(
series: HeatmapSeries[],
): Map<string, number> {
const peaks = new Map<string, number>();
series.forEach((entry) => {
let peak = 0;
entry.points.forEach((point) =>
point.counts.forEach((count) => {
if (count !== null && count > peak) {
peak = count;
}
}),
);
peaks.set(entry.label, peak);
});
return peaks;
}
/** Groups the legend currently has enabled. `undefined` means all of them. */
function resolveVisible(
series: HeatmapSeries[],
visibleGroups: string[] | undefined,
): HeatmapSeries[] {
if (visibleGroups === undefined) {
return series;
}
const allowed = new Set(visibleGroups);
return series.filter((entry) => allowed.has(entry.label));
}
/**
* Pivots the response's column-major counts into the row-major grid the renderer
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
* needs no extra request. A cell is `null` only when no group contributed to it.
*/
export function resolveHeatmapGrid({
buckets,
step,
series,
visibleGroups,
}: {
buckets: number[];
/** Column width in seconds. */
step: number;
series: HeatmapSeries[];
/** Labels the legend has enabled. `undefined` sums every group. */
visibleGroups?: string[];
}): HeatmapGrid {
if (buckets.length === 0 || series.length === 0) {
return EMPTY_GRID;
}
const selected = resolveVisible(series, visibleGroups);
// Groups are not guaranteed to share timestamps, so the columns are their union.
const timestampSet = new Set<number>();
selected.forEach((entry) => {
entry.points.forEach((point) => timestampSet.add(point.timestamp));
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
const rowCount = buckets.length + 1;
const counts: Array<Array<number | null>> = Array.from(
{ length: rowCount },
() => new Array<number | null>(timestamps.length).fill(null),
);
selected.forEach((entry) => {
entry.points.forEach((point) => {
const column = columnOf.get(point.timestamp);
if (column === undefined) {
return;
}
point.counts.forEach((count, row) => {
if (row >= rowCount || count === null || count === undefined) {
return;
}
counts[row][column] = (counts[row][column] ?? 0) + count;
});
});
});
return { bounds: buckets, timestamps, step, counts };
}

View File

@@ -1,170 +0,0 @@
import uPlot from 'uplot';
import {
createHeatmapColorResolver,
HeatmapColorResolver,
resolveCountDomain,
} from './colorScale';
import { resolveColumnIndex, resolveRowIndex } from './geometry';
import {
createHoverOverlay,
HeatmapHoverOverlay,
showHoverOverlay,
} from './hoverOverlay';
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
export interface HeatmapRenderOptions {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
colors: HeatmapColorOptions;
isDarkMode: boolean;
/** Opacity-mode fill when `colors.fill` is empty. */
seriesColor: string;
/** Default true. */
dimOnHover?: boolean;
/** `null` when the cursor leaves. */
onHoverChange?: (cell: HeatmapCell | null) => void;
}
/**
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
* TooltipPlugin's so the focused row is resolved when the tooltip positions
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
*/
export interface HeatmapHooks {
init: (u: uPlot) => void;
draw: (u: uPlot) => void;
setCursor: (u: uPlot) => void;
destroy: (u: uPlot) => void;
}
export function createHeatmapHooks({
yAxis,
step,
colors,
isDarkMode,
seriesColor,
dimOnHover = true,
onHoverChange,
}: HeatmapRenderOptions): HeatmapHooks {
let overlay: HeatmapHoverOverlay | null = null;
let hovered: HeatmapCell | null = null;
let hatchPattern: CanvasPattern | null = null;
// On auto, the domain comes from the data, but these hooks are captured once at
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
// rather than forcing a rebuild.
let cachedData: uPlot.AlignedData | null = null;
let cachedResolver: HeatmapColorResolver | null = null;
function getResolver(u: uPlot): HeatmapColorResolver {
if (cachedResolver && cachedData === u.data) {
return cachedResolver;
}
cachedResolver = createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(
colors,
u.data.slice(1) as Array<Array<number | null>>,
),
isDarkMode,
seriesColor,
});
cachedData = u.data;
return cachedResolver;
}
function clearHover(u: uPlot): void {
if (overlay) {
overlay.container.style.display = 'none';
}
if (hovered === null) {
return;
}
hovered = null;
u.setSeries(null, { focus: true });
onHoverChange?.(null);
}
return {
init: (u: uPlot): void => {
overlay = createHoverOverlay(isDarkMode);
u.over.appendChild(overlay.container);
},
draw: (u: uPlot): void => {
const timestamps = u.data[0] as ArrayLike<number> | undefined;
if (!timestamps?.length || yAxis.rows.length === 0) {
return;
}
const { ctx } = u;
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
ctx.save();
ctx.beginPath();
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
ctx.restore();
drawOverflowBoundary({ u, yAxis, isDarkMode });
},
setCursor: (u: uPlot): void => {
const { left = -10, top = -10 } = u.cursor;
if (left < 0 || top < 0) {
clearHover(u);
return;
}
const column = resolveColumnIndex(
u.data[0] as ArrayLike<number>,
u.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
if (column === null || row === null) {
clearHover(u);
return;
}
if (hovered?.row === row && hovered?.column === column) {
return;
}
hovered = {
row,
column,
count:
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null,
};
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
// uPlot's own focus is disabled here: it picks the series nearest in value
// space, and a heatmap's value is a colour, not a y coordinate.
u.setSeries(row + 1, { focus: true });
if (overlay) {
showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim: dimOnHover,
});
}
onHoverChange?.(hovered);
},
destroy: (): void => {
overlay?.container.remove();
overlay = null;
hovered = null;
hatchPattern = null;
cachedData = null;
cachedResolver = null;
},
};
}

View File

@@ -1,118 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapYAxis } from './types';
const HIGHLIGHT_BORDER_WIDTH = 1;
/** ~55% alpha. */
const DIM_ALPHA = '8C';
export interface HeatmapHoverOverlay {
container: HTMLDivElement;
highlight: HTMLDivElement;
/** Four corner rects whose complement is the hovered row/column cross. */
dims: HTMLDivElement[];
}
function createOverlayElement(): HTMLDivElement {
const element = document.createElement('div');
element.style.position = 'absolute';
element.style.pointerEvents = 'none';
return element;
}
function setRect(
element: HTMLDivElement,
left: number,
top: number,
width: number,
height: number,
): void {
element.style.left = `${left}px`;
element.style.top = `${top}px`;
element.style.width = `${Math.max(0, width)}px`;
element.style.height = `${Math.max(0, height)}px`;
}
/** Kept out of the canvas so moving between cells repositions a few nodes
* instead of repainting the grid. */
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
const container = createOverlayElement();
container.style.inset = '0';
container.style.display = 'none';
container.setAttribute('data-testid', 'heatmap-hover-overlay');
const dimColor = `${
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
}${DIM_ALPHA}`;
const dims = Array.from({ length: 4 }, () => {
const dim = createOverlayElement();
dim.style.background = dimColor;
container.appendChild(dim);
return dim;
});
const highlight = createOverlayElement();
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
}`;
highlight.style.boxSizing = 'border-box';
container.appendChild(highlight);
return { container, highlight, dims };
}
/** Positions the highlight, and the four corner rects so only the hovered row
* and column stay at full contrast. */
export function showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim,
}: {
overlay: HeatmapHoverOverlay;
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
row: number;
column: number;
dim: boolean;
}): void {
const timestamps = u.data[0] as ArrayLike<number>;
const width = u.over.clientWidth;
const height = u.over.clientHeight;
const cellLeft = u.valToPos(timestamps[column], 'x');
const cellRight = u.valToPos(timestamps[column] + step, 'x');
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
setRect(
overlay.highlight,
cellLeft,
cellTop,
cellRight - cellLeft,
cellBottom - cellTop,
);
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
if (dim) {
setRect(topLeft, 0, 0, cellLeft, cellTop);
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
setRect(
bottomRight,
cellRight,
cellBottom,
width - cellRight,
height - cellBottom,
);
} else {
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
}
overlay.container.style.display = 'block';
}

View File

@@ -1,128 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapColorResolver } from './colorScale';
import { HeatmapYAxis } from './types';
/** Cells at least this wide/tall keep a hairline separator. */
const MIN_CELL_SIZE_FOR_GAP = 4;
const HATCH_TILE_SIZE = 6;
const OVERFLOW_DASH: [number, number] = [4, 3];
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
* scrape outage reads as a quiet period. */
export function createHatchPattern(
ctx: CanvasRenderingContext2D,
isDarkMode: boolean,
): CanvasPattern | null {
const pxRatio = uPlot.pxRatio;
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
const tile = document.createElement('canvas');
tile.width = size;
tile.height = size;
const tileCtx = tile.getContext('2d');
if (!tileCtx) {
return null;
}
tileCtx.strokeStyle = isDarkMode
? `${Color.BG_VANILLA_400}59`
: `${Color.BG_INK_300}40`;
tileCtx.lineWidth = Math.max(1, pxRatio);
tileCtx.beginPath();
// Three strokes keep the pattern continuous across tile seams.
tileCtx.moveTo(0, size);
tileCtx.lineTo(size, 0);
tileCtx.moveTo(-size / 2, size / 2);
tileCtx.lineTo(size / 2, -size / 2);
tileCtx.moveTo(size / 2, size * 1.5);
tileCtx.lineTo(size * 1.5, size / 2);
tileCtx.stroke();
return ctx.createPattern(tile, 'repeat');
}
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
// eslint-disable-next-line sonarjs/cognitive-complexity
export function drawCells({
u,
yAxis,
step,
resolver,
hatchPattern,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
resolver: HeatmapColorResolver;
hatchPattern: CanvasPattern | null;
}): void {
const { ctx } = u;
const timestamps = u.data[0] as ArrayLike<number>;
const { rows, edges } = yAxis;
const pxRatio = uPlot.pxRatio;
const xMin = u.scales.x.min ?? timestamps[0];
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
for (let column = 0; column < timestamps.length; column += 1) {
const columnStart = timestamps[column];
const columnEnd = columnStart + step;
if (columnEnd < xMin || columnStart > xMax) {
continue;
}
const left = u.valToPos(columnStart, 'x', true);
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const width = Math.max(1, rawWidth - gapX);
for (let row = 0; row < rows.length; row += 1) {
const top = rowEdgePositions[row + 1];
const rawHeight = rowEdgePositions[row] - top;
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
column
];
const fill = resolver.colorFor(count ?? null);
if (fill === null && hatchPattern === null) {
continue;
}
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
}
}
}
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
* should not be compared with the real buckets. */
export function drawOverflowBoundary({
u,
yAxis,
isDarkMode,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
isDarkMode: boolean;
}): void {
const overflowIndex = yAxis.rows.length - 1;
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
return;
}
const { ctx } = u;
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
ctx.save();
ctx.setLineDash(OVERFLOW_DASH);
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
ctx.beginPath();
ctx.moveTo(u.bbox.left, y);
ctx.lineTo(u.bbox.left + u.bbox.width, y);
ctx.stroke();
ctx.restore();
}

View File

@@ -1,167 +0,0 @@
import { HeatmapColorPalette } from './types';
interface PaletteDefinition {
/** Evenly spaced, one end of the ramp to the other. */
stops: string[];
/** `true` when `stops[0]` is the dark end. */
darkFirst: boolean;
}
/**
* Stop values come from the long-established public palette families —
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
*/
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
[HeatmapColorPalette.Ice]: {
darkFirst: false,
stops: [
'#f7fbff',
'#deebf7',
'#c3dbee',
'#9cc8e2',
'#6daed5',
'#4391c6',
'#2271b4',
'#0c5198',
'#08306b',
],
},
[HeatmapColorPalette.Moss]: {
darkFirst: false,
stops: [
'#f7fcf5',
'#e3f4de',
'#c6e8bf',
'#a0d89b',
'#73c378',
'#45aa5d',
'#228b45',
'#066b2d',
'#00441b',
],
},
[HeatmapColorPalette.Rust]: {
darkFirst: false,
stops: [
'#fff5f0',
'#feddcf',
'#fcbaa1',
'#fc9273',
'#f9694c',
'#eb3d2f',
'#cb1c1e',
'#a10e15',
'#67000d',
],
},
[HeatmapColorPalette.Graphite]: {
darkFirst: false,
stops: [
'#ffffff',
'#efefef',
'#d8d8d8',
'#bbbbbb',
'#979797',
'#737373',
'#505050',
'#262626',
'#000000',
],
},
[HeatmapColorPalette.Ember]: {
darkFirst: false,
stops: [
'#ffffcc',
'#ffeda0',
'#fed676',
'#feb250',
'#fd893c',
'#f8502b',
'#e11e20',
'#b90424',
'#800026',
],
},
[HeatmapColorPalette.Lagoon]: {
darkFirst: false,
stops: [
'#ffffd9',
'#eaf7b8',
'#c1e7b5',
'#81cebb',
'#45b4c2',
'#248fbd',
'#2260a9',
'#20378d',
'#081d58',
],
},
[HeatmapColorPalette.Orchid]: {
darkFirst: false,
stops: [
'#fff7f3',
'#fddfdc',
'#fcc3c3',
'#fa9cb4',
'#f369a3',
'#da3495',
'#ad0a81',
'#7b0176',
'#49006a',
],
},
[HeatmapColorPalette.Verdant]: {
darkFirst: true,
stops: [
'#440154',
'#472d7b',
'#3b528b',
'#2c728e',
'#21918c',
'#28ae80',
'#5ec962',
'#addc30',
'#fde725',
],
},
[HeatmapColorPalette.Lava]: {
darkFirst: true,
stops: [
'#000004',
'#1d1147',
'#51127c',
'#832681',
'#b73779',
'#e75263',
'#fc8961',
'#fec488',
'#fcfdbf',
],
},
[HeatmapColorPalette.Beacon]: {
darkFirst: true,
stops: [
'#002051',
'#11366c',
'#3c4d6e',
'#62646f',
'#7f7c75',
'#9a9478',
'#bbaf71',
'#e2cb5c',
'#fdea45',
],
},
};
/** Stops oriented low-count first for the active theme. At the wrong polarity,
* empty cells become the loudest thing on screen. */
export function getPaletteStops(
palette: HeatmapColorPalette,
isDarkMode: boolean,
): string[] {
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
return definition.darkFirst === isDarkMode
? definition.stops
: [...definition.stops].reverse();
}

View File

@@ -1,113 +0,0 @@
export enum HeatmapColorScale {
Log = 'log',
Sqrt = 'sqrt',
Linear = 'linear',
}
export enum HeatmapColorMode {
Palette = 'palette',
Opacity = 'opacity',
}
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
* read as a threshold that does not exist. */
export enum HeatmapColorPalette {
Ice = 'ice',
Moss = 'moss',
Rust = 'rust',
Graphite = 'graphite',
Ember = 'ember',
Lagoon = 'lagoon',
Orchid = 'orchid',
Verdant = 'verdant',
Lava = 'lava',
Beacon = 'beacon',
}
export interface HeatmapColorOptions {
mode: HeatmapColorMode;
scale: HeatmapColorScale;
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
minCount: number | null;
/** `null` derives it from the grid's highest count. */
maxCount: number | null;
palette: HeatmapColorPalette;
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
* column width in seconds. */
steps: number;
/** Opacity mode. Empty falls back to the caller's series colour. */
fill: string;
}
/** Row-height distribution of the bucket axis. */
export enum HeatmapAxisScale {
Log = 'log',
Linear = 'linear',
}
export interface HeatmapSeriesPoint {
/** Column start, in seconds. */
timestamp: number;
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
counts: Array<number | null>;
}
export interface HeatmapSeriesLabel {
key: string;
value: string;
}
export interface HeatmapSeries {
/** Group label, as the legend names it. Empty when there is no grouping. */
label: string;
/** The pairs behind `label`, letting the tooltip name rows by value alone. */
labels?: HeatmapSeriesLabel[];
points: HeatmapSeriesPoint[];
}
/** Counts pivoted into rows and aligned to one column axis. Internal to the
* chart, which resolves it from `buckets` and `series`. */
export interface HeatmapGrid {
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
bounds: number[];
/** Column starts, in seconds. */
timestamps: number[];
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
* and the last column has no successor to infer it from. */
step: number;
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
* at the bottom of the scale — conflating them hides an outage. */
counts: Array<Array<number | null>>;
}
export interface HeatmapRow {
/** Synthetic on the underflow row. */
lower: number;
/** Synthetic on the overflow row. */
upper: number;
isUnderflow: boolean;
isOverflow: boolean;
}
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
* ticks and uPlot's decade-only label filter cannot hide them. */
export interface HeatmapYAxis {
rows: HeatmapRow[];
/** Row edges, ascending. Length is `rows.length + 1`. */
edges: number[];
/** Real bucket boundaries — one tick each. */
splits: number[];
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
* which would sit half a row from the last boundary and collide with it. */
overflowSplit: number | null;
toBucketValue: (axisValue: number) => number;
min: number;
max: number;
}
export interface HeatmapCell {
row: number;
column: number;
count: number | null;
}

View File

@@ -41,9 +41,6 @@ export default function ChartWrapper({
customTooltip,
pinnedTooltipElement,
tooltipPortalRoot,
customLegend,
legendLabels,
contentFooter,
'data-testid': testId,
}: ChartWrapperProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -64,10 +61,6 @@ export default function ChartWrapper({
if (!showLegend) {
return null;
}
// Charts whose legend does not list uPlot series supply their own.
if (customLegend) {
return customLegend(averageLegendWidth);
}
return (
<UPlotLegend
config={config}
@@ -76,7 +69,7 @@ export default function ChartWrapper({
/>
);
},
[config, legendConfig.position, showLegend, customLegend],
[config, legendConfig.position, showLegend],
);
const renderTooltipCallback = useCallback(
@@ -107,8 +100,6 @@ export default function ChartWrapper({
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
seriesLabels={legendLabels}
contentFooter={contentFooter}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (

View File

@@ -1,310 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
resolveGroupPeaks,
resolveHeatmapGrid,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapAxisScale,
HeatmapCell,
HeatmapColorMode,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { HeatmapChartProps } from 'lib/visualization/charts/types';
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
import { buildHeatmapConfig, prepareHeatmapChartData } from './utils';
/** Vertical space the colour bar takes out of the container. */
const COLOR_BAR_HEIGHT = 28;
/**
* Columns are time slices, rows are bucket ranges, cell colour is the observation
* count — so a distribution can be watched changing shape instead of collapsing to
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
* ~9,600 cells, far past what per-cell DOM carries.
*/
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
const {
id,
buckets,
step,
series,
width,
height,
isDarkMode,
axisScale = HeatmapAxisScale.Log,
yAxisUnit,
decimalPrecision,
timezone,
showVisualMap = true,
showLegend = true,
legendPosition = LegendPosition.BOTTOM,
dimOnHover = true,
showTooltip = true,
canPinTooltip = false,
pinKey,
seriesColor,
minTimeScale,
maxTimeScale,
onDragSelect,
onCellClick,
renderTooltipFooter,
tooltipPortalRoot,
layoutChildren,
'data-testid': testId,
} = props;
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
const hoveredCellRef = useRef<HeatmapCell | null>(null);
const onCellClickRef = useRef(onCellClick);
onCellClickRef.current = onCellClick;
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
// One series has nothing to choose between.
const hasGroupLegend = showLegend && groups.length > 1;
const colors = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
[props.colors],
);
// The opacity fill no longer follows a group colour: with several groups enabled
// at once there is no single one to follow.
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
// read against the panel.
const extremeColor = resolveExtremeColor({
options: colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
});
const {
visibleGroups,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = useHeatmapGroupLegend({ groups });
const grid = useMemo(
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
[buckets, step, series, visibleGroups],
);
const yAxis = useMemo(
() => resolveHeatmapYAxis(grid.bounds, axisScale),
[grid.bounds, axisScale],
);
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
const data = useMemo(
() =>
hasGrid
? prepareHeatmapChartData(grid, yAxis.rows.length)
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
[grid, yAxis.rows.length, hasGrid],
);
const colorResolver = useMemo(
() =>
createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(colors, grid.counts),
isDarkMode,
seriesColor: resolvedSeriesColor,
}),
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
);
// Stable: the renderer captures it at config-build time, so a new identity would
// recreate the plot on every hover.
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
hoveredCellRef.current = cell;
setHoveredCell(cell);
}, []);
const config = useMemo(
() =>
buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
dimOnHover,
onHoverChange: handleHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
id,
grid,
yAxis,
colors,
isDarkMode,
resolvedSeriesColor,
dimOnHover,
handleHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
// Each marker takes the ramp colour for where that group's densest cell falls on
// the colour bar, so a swatch reads against the same scale as the grid.
const groupPeaks = useMemo(() => resolveGroupPeaks(series), [series]);
const isPaletteMode = colors.mode === HeatmapColorMode.Palette;
const legendItems = useMemo<LegendItem[]>(
() =>
groups.map((group, index) => ({
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
// handling is identical across charts.
seriesIndex: index + 1,
label: group,
color: isPaletteMode
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
: extremeColor,
show: visibleGroups.includes(group),
})),
[
groups,
visibleGroups,
isPaletteMode,
colorResolver,
groupPeaks,
extremeColor,
],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => (
<HeatmapTooltip
{...args}
id={id}
yAxis={yAxis}
step={grid.step}
series={series}
visibleGroups={visibleGroups}
groupColor={extremeColor}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
timezone={timezone}
canPinTooltip={canPinTooltip}
renderTooltipFooter={renderTooltipFooter}
/>
),
[
id,
yAxis,
grid.step,
series,
visibleGroups,
extremeColor,
yAxisUnit,
decimalPrecision,
timezone,
canPinTooltip,
renderTooltipFooter,
],
);
const handleClick = useCallback((clickData: ChartClickData): void => {
if (hoveredCellRef.current) {
onCellClickRef.current?.(hoveredCellRef.current, clickData);
}
}, []);
const groupLegend = useCallback(
(averageLegendWidth: number): React.ReactNode => (
<Legend
items={legendItems}
position={legendPosition}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
/>
),
[
legendItems,
legendPosition,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
],
);
const visualMap = useMemo(() => {
if (!showVisualMap || !hasGrid) {
return null;
}
return (
<ColorBar
label="count"
ramp={colorResolver.ramp}
minLabel={colorResolver.domain.min.toLocaleString()}
maxLabel={colorResolver.domain.max.toLocaleString()}
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
/>
);
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
return (
<ChartWrapper
config={config}
data={data}
width={width}
height={
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
}
legendConfig={{ position: legendPosition }}
showLegend={hasGroupLegend}
customLegend={groupLegend}
legendLabels={groups}
showTooltip={showTooltip}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onCellClick ? handleClick : undefined}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
customTooltip={renderTooltip}
renderTooltipFooter={renderTooltipFooter}
tooltipPortalRoot={tooltipPortalRoot}
contentFooter={visualMap}
layoutChildren={layoutChildren}
data-testid={testId}
/>
);
}

View File

@@ -1,252 +0,0 @@
import type React from 'react';
import userEvent from '@testing-library/user-event';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen } from 'tests/test-utils';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapColorMode,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import Heatmap from '../Heatmap';
// The shared Legend virtualises its items; render them all so they are queryable.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div>
{data.map((item, index) => (
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const BUCKETS = [128, 256, 1024];
const STEP = 60;
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
const SERIES: HeatmapSeries[] = [
{
label: 'service.name=cart',
points: [
{ timestamp: 1000, counts: [1, 4, 7, 10] },
{ timestamp: 1060, counts: [2, null, 8, 11] },
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
],
},
{
label: 'service.name=checkout',
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
},
];
function renderHeatmap(
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
): ReturnType<typeof render> {
return render(
<Heatmap
id="panel-1"
buckets={BUCKETS}
step={STEP}
series={SERIES}
width={800}
height={400}
isDarkMode
data-testid="heatmap"
{...props}
/>,
);
}
describe('Heatmap', () => {
it('renders the plot container', () => {
renderHeatmap();
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
});
it('shows the colour bar with the resolved count domain', () => {
renderHeatmap();
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('puts the colour bar against the plot, with the legend after it', () => {
renderHeatmap();
const bar = screen.getByTestId('color-bar');
const legend = screen.getByText('service.name=cart').closest('.legend-item');
// The bar is the scale key for the grid, so it reads before the controls.
expect(
bar.compareDocumentPosition(legend as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('keeps the colour bar inside the chart column, not below the legend', () => {
renderHeatmap();
expect(
screen.getByTestId('color-bar').closest('.chart-layout__content'),
).not.toBeNull();
});
it('hides the colour bar when the visual map is off', () => {
renderHeatmap({ showVisualMap: false });
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('labels the colour bar with an explicit clamp instead of the data range', () => {
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('500')).toBeInTheDocument();
});
it('falls back to the no-data state when the metric has no buckets', () => {
renderHeatmap({ buckets: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('falls back to the no-data state when no columns came back', () => {
renderHeatmap({ series: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
});
});
describe('Heatmap group legend', () => {
const CART = 'service.name=cart';
const CHECKOUT = 'service.name=checkout';
function legendItem(label: string): HTMLElement {
const item = screen.getByText(label).closest('.legend-item');
if (!item) {
throw new Error(`no legend item for ${label}`);
}
return item as HTMLElement;
}
function marker(label: string): HTMLElement {
const element = legendItem(label).querySelector<HTMLElement>(
'[data-is-legend-marker]',
);
if (!element) {
throw new Error(`no marker for ${label}`);
}
return element;
}
it('lists the groups, with no combined-view entry', () => {
renderHeatmap();
expect(screen.getByText(CART)).toBeInTheDocument();
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
});
it('enables every group to begin with', () => {
renderHeatmap();
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
});
it('isolates a group when its label is clicked', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
});
it('restores every group when the isolated label is clicked again', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
await userEvent.click(screen.getByText(CART));
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
});
it('excludes just one group when its marker is clicked', async () => {
renderHeatmap();
await userEvent.click(marker(CHECKOUT));
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
});
/** The ramp the cells and colour bar are drawn from, for the default options. */
function activeRamp(): string[] {
const grid = resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: SERIES,
});
return createHeatmapColorResolver({
options: DEFAULT_HEATMAP_COLORS,
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
isDarkMode: true,
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
}).ramp.map((color) => color.toLowerCase());
}
it('places each marker where its group sits on the colour bar', () => {
renderHeatmap();
const ramp = activeRamp();
// cart peaks at 1200, checkout at 4, so cart sits further along the ramp.
// The DOM lowercases hex; the ramp is built uppercase.
const cart = ramp.indexOf(marker(CART).style.borderColor.toLowerCase());
const checkout = ramp.indexOf(
marker(CHECKOUT).style.borderColor.toLowerCase(),
);
expect(cart).toBeGreaterThan(-1);
expect(checkout).toBeGreaterThan(-1);
expect(cart).toBeGreaterThan(checkout);
});
it('gives every marker the solid fill in opacity mode', () => {
renderHeatmap({
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
});
// A partially transparent marker is hard to read against the panel.
expect(marker(CART).style.borderColor).toBe(
marker(CHECKOUT).style.borderColor,
);
expect(marker(CART).style.borderColor).not.toBe('');
});
it('hides the legend when there is only one group to choose from', () => {
renderHeatmap({ series: [SERIES[0]] });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
});
it('hides the legend when asked', () => {
renderHeatmap({ showLegend: false });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
});
});

View File

@@ -1,147 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { MouseEvent } from 'react';
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
const GROUPS = ['cart', 'checkout', 'payments'];
/** Mimics a click on an item's label, as the shared Legend renders it. */
function labelClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
const label = document.createElement('span');
wrapper.appendChild(label);
return { target: label } as unknown as MouseEvent<HTMLDivElement>;
}
/** Mimics a click on the item's marker circle. */
function markerClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
const marker = document.createElement('div');
marker.dataset.isLegendMarker = 'true';
wrapper.appendChild(marker);
return { target: marker } as unknown as MouseEvent<HTMLDivElement>;
}
function render(
groups: string[] = GROUPS,
): ReturnType<
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
> {
return renderHook(() => useHeatmapGroupLegend({ groups }));
}
describe('useHeatmapGroupLegend', () => {
it('enables every group to begin with', () => {
const { result } = render();
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('isolates a group when its label is clicked', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(2)));
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('restores every group when the isolated label is clicked again', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(2)));
act(() => result.current.onLegendClick(labelClick(2)));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('moves the isolation when a different label is clicked', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(1)));
act(() => result.current.onLegendClick(labelClick(3)));
expect(result.current.visibleGroups).toStrictEqual(['payments']);
});
it('excludes just one group when its marker is clicked', () => {
const { result } = render();
act(() => result.current.onLegendClick(markerClick(2)));
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
});
it('re-includes a group when its marker is clicked again', () => {
const { result } = render();
act(() => result.current.onLegendClick(markerClick(2)));
act(() => result.current.onLegendClick(markerClick(2)));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('excludes more than one group', () => {
const { result } = render();
act(() => result.current.onLegendClick(markerClick(1)));
act(() => result.current.onLegendClick(markerClick(3)));
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('drops the isolation when a marker is clicked, so the label can isolate again', () => {
const { result } = render();
act(() => result.current.onLegendClick(labelClick(1)));
// Re-including cart by marker leaves it enabled but no longer isolated.
act(() => result.current.onLegendClick(markerClick(2)));
act(() => result.current.onLegendClick(labelClick(1)));
expect(result.current.visibleGroups).toStrictEqual(['cart']);
});
it('allows every group to be excluded, as the other legends do', () => {
const { result } = render();
GROUPS.forEach((_, index) =>
act(() => result.current.onLegendClick(markerClick(index + 1))),
);
expect(result.current.visibleGroups).toStrictEqual([]);
});
it('ignores clicks that miss an entry', () => {
const { result } = render();
const stray = {
target: document.createElement('div'),
} as unknown as MouseEvent<HTMLDivElement>;
act(() => result.current.onLegendClick(stray));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('forgets a hidden group that left the result', () => {
const { result, rerender } = renderHook(
({ groups }) => useHeatmapGroupLegend({ groups }),
{ initialProps: { groups: GROUPS } },
);
act(() => result.current.onLegendClick(markerClick(3)));
rerender({ groups: ['cart', 'checkout'] });
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
});
it('tracks the hovered entry for the legend"s focus highlight', () => {
const { result } = render();
act(() => result.current.onLegendMouseMove(labelClick(2)));
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
expect(result.current.focusedSeriesIndex).toBeNull();
});
});

View File

@@ -1,195 +0,0 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapAxisScale,
HeatmapGrid,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type uPlot from 'uplot';
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
const GRID: HeatmapGrid = {
bounds: [128, 256, 1024],
timestamps: [1000, 1060, 1120],
step: 60,
counts: [
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
[0, 0, 0],
],
};
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
/** Tall enough that no tick needs thinning. */
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
function readSplits(
config: ReturnType<typeof buildHeatmapConfig>,
plot: uPlot,
): number[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
}
function readLabels(
config: ReturnType<typeof buildHeatmapConfig>,
splits: number[],
): string[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
{} as uPlot,
splits,
);
}
function readRange(scale?: uPlot.Scale): [number, number] {
const range = scale?.range as (
u: uPlot,
min: number,
max: number,
) => [number, number];
return range({} as uPlot, 0, 0);
}
function buildConfig(
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
): ReturnType<typeof buildHeatmapConfig> {
return buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
...overrides,
});
}
describe('prepareHeatmapChartData', () => {
it('puts timestamps first and one series per bucket row', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
expect(data[0]).toStrictEqual(GRID.timestamps);
expect(data[1]).toStrictEqual([1, 2, 3]);
});
it('preserves null cells rather than zeroing them', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data[2]).toStrictEqual([4, null, 6]);
});
it('pads short rows so every uPlot data array is the same length', () => {
const data = prepareHeatmapChartData(
{ ...GRID, counts: [[1]] },
Y_AXIS.rows.length,
);
expect(data[1]).toStrictEqual([1, null, null]);
});
it('pads missing rows up to the resolved row count', () => {
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
expect(data).toHaveLength(3);
expect(data[2]).toStrictEqual([null, null, null]);
});
});
describe('buildHeatmapConfig', () => {
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
const config = buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
}).getConfig();
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
});
it('draws no paths or points per series — the renderer paints the cells', () => {
const [, firstRow] = buildConfig().getConfig().series ?? [];
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
expect((firstRow as uPlot.Series).points?.show).toBe(false);
});
it('labels series by bucket range, including the open-ended rows', () => {
const labels = (buildConfig().getConfig().series ?? [])
.slice(1)
.map((series) => series.label);
expect(labels[0]).toContain('≤');
expect(labels[labels.length - 1]).toContain('>');
});
it('spans the x scale to the end of the last column, not its start', () => {
const { x } = buildConfig().getConfig().scales ?? {};
expect(readRange(x)).toStrictEqual([1000, 1180]);
});
it('prefers the query window over the grid extent', () => {
const { x } =
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
{};
expect(readRange(x)).toStrictEqual([900, 1500]);
});
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
const { y } = buildConfig().getConfig().scales ?? {};
expect(y?.auto).toBe(false);
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
});
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
const splits = readSplits(buildConfig(), TALL_PLOT);
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
});
it('labels the overflow edge as infinite and the rest by bucket value', () => {
const config = buildConfig();
const labels = readLabels(config, readSplits(config, TALL_PLOT));
expect(labels[0]).toBe('128');
expect(labels[labels.length - 1]).toBe('∞');
});
it('thins the tick set when the panel is too short to label every boundary', () => {
const config = buildConfig();
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
// The infinite edge is the one label that must never be dropped.
expect(readLabels(config, splits).at(-1)).toBe('∞');
});
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
const config = buildConfig().getConfig();
expect(config.cursor?.focus?.prox).toBe(-1);
expect(config.cursor?.points?.show).toBe(false);
});
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
});
it('registers the renderer hooks', () => {
const { hooks } = buildConfig().getConfig();
expect(hooks?.init).toHaveLength(1);
expect(hooks?.draw).toHaveLength(1);
expect(hooks?.setCursor).toHaveLength(1);
expect(hooks?.destroy).toHaveLength(1);
});
});

View File

@@ -1,100 +0,0 @@
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react';
export interface UseHeatmapGroupLegendResult {
/** Groups currently enabled. The grid sums exactly these. */
visibleGroups: string[];
focusedSeriesIndex: number | null;
onLegendClick: (event: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
/** The shared Legend tags each item and delegates interaction to the container. */
function getLegendIndex(event: MouseEvent<HTMLDivElement>): number | null {
const element = (event.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = element?.dataset.legendItemId;
return id === undefined ? null : Number(id);
}
function isMarkerClick(event: MouseEvent<HTMLDivElement>): boolean {
return Boolean((event.target as HTMLElement).dataset.isLegendMarker);
}
/**
* Group visibility for the heatmap legend, matching every other legend in the
* product: the label isolates a group, the marker excludes one, and everything is
* enabled to begin with. Counts are additive, so whatever is enabled is summed
* client-side and needs no extra request.
*
* Visibility only. Marker colour is resolved by the caller, which owns the colour
* ramp — and that ramp depends on which groups this hook has enabled.
*/
export function useHeatmapGroupLegend({
groups,
}: {
groups: string[];
}): UseHeatmapGroupLegendResult {
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
null,
);
const isolatedRef = useRef<string | null>(null);
const visibleGroups = useMemo(
() => groups.filter((group) => !hidden.has(group)),
[groups, hidden],
);
const onLegendClick = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(event);
const group = index === null ? undefined : groups[index - 1];
if (group === undefined) {
return;
}
if (isMarkerClick(event)) {
isolatedRef.current = null;
setHidden((previous) => {
const next = new Set(previous);
if (next.has(group)) {
next.delete(group);
} else {
next.add(group);
}
return next;
});
return;
}
// Label click isolates; clicking the isolated group again restores all.
const isReset = isolatedRef.current === group;
isolatedRef.current = isReset ? null : group;
setHidden(
isReset ? new Set() : new Set(groups.filter((entry) => entry !== group)),
);
},
[groups],
);
const onLegendMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
setFocusedSeriesIndex(getLegendIndex(event));
},
[],
);
const onLegendMouseLeave = useCallback((): void => {
setFocusedSeriesIndex(null);
}, []);
return {
visibleGroups,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -1,193 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
decimateAxisSplits,
formatRowLabel,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
createHeatmapHooks,
HeatmapRenderOptions,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
import { HeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import uPlot from 'uplot';
/** Minimum gap between y tick labels, in CSS pixels. */
const MIN_Y_TICK_GAP_PX = 18;
/** Label for the edge above the overflow row. */
const OVERFLOW_AXIS_LABEL = '∞';
/**
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
*
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
* the response carried duplicate boundaries — since uPlot requires equal lengths.
*/
export function prepareHeatmapChartData(
grid: HeatmapGrid,
rowCount: number,
): uPlot.AlignedData {
const columnCount = grid.timestamps.length;
const rows = Array.from({ length: rowCount }, (_, row) => {
const counts = grid.counts[row] ?? [];
return Array.from({ length: columnCount }, (_, column) =>
counts[column] === undefined ? null : counts[column],
);
});
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
}
export interface BuildHeatmapConfigArgs extends Omit<
HeatmapRenderOptions,
'step'
> {
id: string;
grid: HeatmapGrid;
/** Unit of the bucket boundaries; counts are never formatted with it. */
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
timezone?: Timezone;
/** Query window, in seconds. Falls back to the grid's own extent. */
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect?: (startTime: number, endTime: number) => void;
}
export function buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor,
dimOnHover,
onHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
const tzDate = timezone
? (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
: undefined;
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
// uPlot's focus picks the series closest in value space, meaningless when the
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
// that call off uPlot's full-redraw path.
builder.setFocus({ alpha: 1 });
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
const xRange: [number, number] = [
minTimeScale ?? grid.timestamps[0] ?? 0,
maxTimeScale ?? lastTimestamp + grid.step,
];
builder.addScale({
scaleKey: 'x',
time: true,
range: (): [number, number] => xRange,
});
builder.addScale({
scaleKey: 'y',
time: false,
auto: false,
range: (): [number, number] => [yAxis.min, yAxis.max],
});
builder.addAxis({
scaleKey: 'x',
side: 2,
isDarkMode,
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
});
// Ticks sit on row edges, so the overflow row is the band between the last
// boundary and `∞`. A centre label would sit half a row from the boundary tick
// and collide with it.
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
const hasOverflowTick =
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
const axisSplits = hasOverflowTick
? [...yAxis.splits, yAxis.overflowSplit as number]
: yAxis.splits;
// From the boundaries themselves, not by inverting the transform:
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
const splitLabels = new Map<number, string>();
yAxis.splits.forEach((split, index) => {
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
});
if (hasOverflowTick) {
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
}
builder.addAxis({
scaleKey: 'y',
side: 3,
isDarkMode,
yAxisUnit,
decimalPrecision,
// Thinned to whatever fits: a histogram can carry more boundaries than the
// panel has room to label.
splits: (self): number[] =>
decimateAxisSplits({
splits: axisSplits,
min: yAxis.min,
max: yAxis.max,
plotHeight: self.bbox.height / uPlot.pxRatio,
minGapPx: MIN_Y_TICK_GAP_PX,
}),
values: (_, splits): string[] =>
splits.map(
(split) =>
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
),
});
yAxis.rows.forEach((row) => {
builder.addSeries({
scaleKey: 'y',
// Nothing is stroked per series; the draw hook paints the grid.
drawStyle: DrawStyle.Line,
pathBuilder: (): null => null,
showPoints: false,
spanGaps: false,
label: formatRowLabel(row, formatBucketValue),
colorMapping: {},
isDarkMode,
});
});
const hooks = createHeatmapHooks({
yAxis,
step: grid.step,
colors,
isDarkMode,
seriesColor,
dimOnHover,
onHoverChange,
});
// Order matters — see the HeatmapHooks doc comment.
builder.addHook('init', hooks.init);
builder.addHook('draw', hooks.draw);
builder.addHook('setCursor', hooks.setCursor);
builder.addHook('destroy', hooks.destroy);
return builder;
}

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