Compare commits

..

12 Commits

Author SHA1 Message Date
Gaurav Tewari
f861bc0487 chore: remove comment back 2026-09-21 19:17:46 +05:30
Gaurav Tewari
c25bd860c6 chore: update test file 2026-09-21 19:09:59 +05:30
Abhi kumar
a2e42df790 fix(charts): stop tooltip labels breaking mid-word (#12894)
#### Description

- Chart tooltip labels were breaking mid-word (`Metric` / `s`) whenever
a row's value was wide. `overflow-wrap: anywhere` drops a flex item's
min-content width to one character, so the label was the only thing in
the row that could give way — the value and the dashed leader kept their
width and squeezed it to nothing.
- The label now uses `overflow-wrap: break-word`, so it breaks a token
only when a line can't hold it whole, and wraps at 3 lines before
ellipsizing. `title` carries the full text.
- Tooltip width floor goes 300 → 360. The width comes from the *legend*
label length, so charts with short series names (billing) got the
narrowest box even though their values are the longest. 360 is already
the upper bound that formula produces, so no tooltip is wider than the
widest one today, and the width still never changes between hovers.

#### Screenshots / Screen Recordings

| Before | After |
| --- | --- |
| Label crushed to ~40px, broken mid-word | Label intact on one line,
value pinned at the row end |

#### Additional Information

Verified in a headless browser at the new width: short labels stay on
one line even with a longer value than the reported case, long series
names wrap at token boundaries with the value still at the end of the
row, and no row overflows horizontally.

Closes https://github.com/SigNoz/pulse-pod/issues/365
2026-09-21 11:57:13 +00:00
Naman Verma
f9928aa4db feat: add plugin schema for area chart panel (#12635)
#### Description

Add a new plugin schema for area chart. This also adds a new enum for
fill mode specific to area charts cuz the default is different wrt time
series chart.

Frontend changes to be built on top of this

#### Issues closed by this PR

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

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-09-21 11:14:29 +00:00
Aditya Singh
1c7811c414 refactor(log-details): move infra metrics charts to v5 query_range (#12916)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- moves the metrics tab in log details + span details (same InfraMetrics
component) from v4 to v5 query_range.. pod, node and host charts
- no payload rewrite, the v5 preparer already handles the legacy builder
shape so constants stay as is
- having helper now always emits the v5 expression form and the
`useV5HavingFormat` flag is gone. host payload is shared with Hosts V2
which was already on v5, so both callers line up now
- fixed legends while here.. on v5 the response has per-query metadata
and getLegend was resolving against the explorer's currentQuery, so
CPU/Memory usage showed `count()` instead of the pod name. charts now
pass their own payload query, same as K8s V2 EntityMetrics
- node/pod toggle: re-clicking the pressed pill deselects (radix single
toggle-group emits '') and blanked the tab. ignoring empty values now.
pre-existing, component fix tracked in
https://github.com/SigNoz/components/issues/402

#### Issues closed by this PR

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




https://github.com/user-attachments/assets/62826754-e1eb-44b3-af60-50a2b614048d



#### Additional Information

- staging has no bare-vm logs (host.name without k8s.node.name), so the
host chart path from the drawer wasn't hit directly.. verified via Hosts
details which uses the same getHostQueryPayload
- cc. @H4ad
2026-09-21 09:01:08 +00:00
Abhi kumar
1839648e75 fix(legend): isolate legend stacking context (#12924)
#### Description

- Legend row markers carry `z-index: 4`, and nothing between them and
the page root established a stacking context, so they competed with (and
beat) the logs explorer quick filter panel at `z-index: 2` — the colored
markers painted on top of the panel.
- `isolation: isolate` on the legend container keeps the row-level
z-indexes local. The legend is a flex sibling of the plot and its
tooltips portal to body, so nothing relied on them escaping.

Before
<img width="2032" height="1048" alt="image"
src="https://github.com/user-attachments/assets/e2c792b7-930c-477f-aeb5-4867434b46cd"
/>


After
<img width="1336" height="493" alt="image"
src="https://github.com/user-attachments/assets/35a67f89-ea97-4773-b050-2f39321e9468"
/>
2026-09-21 08:07:50 +00:00
Nityananda Gohain
ea8f95ee08 chore: enable ai 011y processors by default (#12912)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
<!--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
Enable the processors by default so that metadata is populated

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
No issue
2026-09-19 13:02:43 +00:00
Nityananda Gohain
b64116d67d feat: support for default attribute mapping (#12809)
<!--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

Until now every org started with no span mapper groups and we had to
create `llm`, `agent` and `tool` by hand. This PR ships them as
defaults.

- The three groups live as JSON in the binary. On startup and when a new
org is created, they get seeded. When we change a definition and bump
its version, the next release updates the org's copy in place.
- Anything SigNoz ships is marked `origin: system` and can only be
switched on or off. Users can't rename or delete these groups and
mappers, and can't take their names. Anything the user adds is theirs to
edit or remove, including new mappers in a shipped group or new sources
on a shipped mapper.
  - Upgrades keep the user's on/off choices and never touch their items.
- Condition substrings and sources now carry `enabled` and `origin`, so
a substring is an object instead of a plain string. Disabled ones are
left out of the collector config.
- - Migration 127 only adds the `origin` and `version` columns. Stored
JSON is not rewritten because these tables are empty on every instance.
- Fixes creating a group or mapper with `enabled: false` being saved as
true (the bun `default:true` tag turned false into SQL `DEFAULT`)
  
Frontend: no UI changes. Generated client regenerated; drafts carry
`enabled` and `origin` so saves from the existing screens round-trip
shipped items intact.

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


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
* Frontend follow-up: toggles for sources and substrings, "Default"
badge and read-only rows for shipped items, hide rename/delete on system
groups and mappers.
* Deferred: per-group upgrade changelog ( will come back to this later)

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-19 11:22:46 +00:00
Naman Verma
2068482f66 feat: add api to repair malformed channels created via v1 (#12910)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

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

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

Part of https://github.com/SigNoz/pulse-pod/issues/342
2026-09-18 16:05:41 +00:00
Gaurav Tewari
35973efd65 feat(quick-filters): AI o11y quick filters (#12788)
#### Description

- The AI o11y explorer was reusing the traces explorer's quick filters.
It now uses its own `ai_observability` source and signal, so it loads
the gen_ai filter set.
- Values and settings keys go to
`/api/v1/ai_observability/fields/{values,keys}`, which scope suggestions
to gen_ai spans.


#### Issues closed by this PR
Close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108147&issue=SigNoz%7Cengineering-pod%7C5844

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/f7124648-9919-46e5-9e6d-90bd72f91a50


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 09:49:13 +00:00
Naman Verma
c65845e525 feat: add more slack configuration opts in notification channels (#12907)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Some common configuration options for a slack notification have been
added.

Also, webhook notification channels can now run without a username,
without a password, or without any auth.
2026-09-18 09:20:19 +00:00
Gaurav Tewari
f6f41df237 feat(ai-observability): Trace view changes (#12796)
#### Description

- Trace is now the explorer's default view (`DEFAULT_PANEL_TYPE`) and
the first toolbar tab.
- `LeftToolbarActions` becomes config-driven: buttons render in the
order the caller declares its views, from a `TOOLBAR_VIEW_CONFIG`
lookup, instead of five hardcoded per-view blocks. That also replaces
the `items: any` prop with a typed `Record<string, ToolbarViewItem>`.
- Fixes a column-init race in the trace view. Rows can land before the
field keys, and mounting then persisted a partial column set as if the
user had chosen it. `useTraceViewColumns` now hands out the column
storage key only once the keys fetch succeeds — every write path in
`useColumnState` no-ops without one, so the key is the write barrier.
Until then the table stays unmounted, the Options control is hidden, and
a render falls back to the default-visible columns with no key attached.
- Removes the saved-views / export-to-dashboard bar from the explorer
and the download menu from the list view — its export path only handles
`PANEL_TYPES.LIST`, so it could not reflect the selected columns.
`getQueryByPanelType` and `getExportQueryData` go with them. Table and
Time Series keep their exports under AI-specific filenames, via a new
opt-in `exportFileName` on the shared `TimeSeriesView` (defaulted, so
existing callers are unchanged).
- List view drops `useOptionsMenu`: columns are the static
`defaultSelectedColumns` (now typed `TelemetryFieldKey[]`) until the
preferences framework lands, and the table keeps its own column order
under `AI_OBSERVABILITY_LIST_COLUMNS` instead of sharing the traces
explorer's.
- `start_time`/`end_time`/`last_activity_time` join
`TIMESTAMP_FIELD_NAMES` and
`trace_duration_nano`/`max_llm_duration_nano` join
`DURATION_FIELD_NAMES`, so the shared `FieldCell` formats the
trace-level columns instead of a separate component.
- The explorer now imports its own forked `aiActions`, `Controls`,
`TracesTable` and list utils rather than reaching into `TracesExplorer`.
- Trims the forked `ListView/utils.tsx` to the two helpers the AI
explorer uses. `getListColumns`, `BlockLink` and `transformDataWithDate`
are antd-era machinery whose only consumer is `TracesTableComponent`,
which still imports them from the untouched original.
- Tests for the trace view, its column hook, and the table's column-init
race.
- Unrelated one-liner: `FieldKeysConfig`/`FieldValuesConfig` now point
at the generic endpoint's param types instead of being a union with the
AI ones. `Omit` over a union keeps only the keys both members share, so
`FieldKeysConfigProp` was silently losing `source`, `metricName` and
`metricNamespace`, and metrics/meter could not have used the picker. The
AI params are a subset of the generic ones and the endpoint ignores
extras.

#### Issues closed by this PR

close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108466&issue=SigNoz%7Cengineering-pod%7C5845

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/b593e160-f81e-49d4-a092-20d86bc46515

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 08:36:26 +00:00
232 changed files with 6638 additions and 11545 deletions

View File

@@ -171,6 +171,14 @@ components:
- kind
- spec
type: object
AlertmanagertypesChannelDefect:
enum:
- none
- missing_type
- multiple_notifiers
- unsupported_notifier
- unrepresentable
type: string
AlertmanagertypesChannelEmailConfig:
properties:
headers:
@@ -384,13 +392,83 @@ components:
required:
- routingKey
type: object
AlertmanagertypesChannelRepair:
properties:
action:
$ref: '#/components/schemas/AlertmanagertypesChannelRepairAction'
applied:
type: boolean
blockers:
items:
type: string
type: array
channels:
items:
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
nullable: true
type: array
defect:
$ref: '#/components/schemas/AlertmanagertypesChannelDefect'
detail:
type: string
id:
type: string
required:
- id
- defect
- action
- applied
type: object
AlertmanagertypesChannelRepairAction:
enum:
- none
- retype
- split
- delete
type: string
AlertmanagertypesChannelSlackAction:
properties:
confirm:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
name:
type: string
style:
type: string
text:
type: string
type:
type: string
url:
type: string
value:
type: string
required:
- type
- text
type: object
AlertmanagertypesChannelSlackConfig:
properties:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
type: array
apiUrl:
format: password
type: string
channel:
type: string
color:
type: string
fallback:
type: string
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
type: array
footer:
type: string
pretext:
type: string
sendResolved:
nullable: true
type: boolean
@@ -398,9 +476,37 @@ components:
type: string
title:
type: string
titleLink:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelSlackConfirmation:
properties:
dismissText:
type: string
okText:
type: string
text:
type: string
title:
type: string
required:
- text
type: object
AlertmanagertypesChannelSlackField:
properties:
short:
nullable: true
type: boolean
title:
type: string
value:
type: string
required:
- title
- value
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
@@ -969,6 +1075,11 @@ components:
- duration
- repeatType
type: object
AlertmanagertypesRepairChannelParams:
properties:
apply:
type: boolean
type: object
AlertmanagertypesRepeatOn:
enum:
- sunday
@@ -3210,6 +3321,53 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -3441,6 +3599,11 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -3552,79 +3715,6 @@ components:
hide:
type: boolean
type: object
DashboardtypesHeatmapAxes:
properties:
yScale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3976,8 +4066,8 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -3989,26 +4079,38 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -4021,18 +4123,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
@@ -4351,6 +4441,12 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
@@ -9709,6 +9805,8 @@ components:
type: string
name:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
@@ -9721,6 +9819,7 @@ components:
- fieldContext
- config
- enabled
- origin
type: object
SpantypesSpanMapperConfig:
properties:
@@ -9749,48 +9848,75 @@ components:
type: string
orgId:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
version:
type: integer
required:
- id
- orgId
- name
- condition
- enabled
- origin
- version
type: object
SpantypesSpanMapperGroupCondition:
nullable: true
properties:
attributes:
items:
type: string
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
nullable: true
type: array
resource:
items:
type: string
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
nullable: true
type: array
required:
- attributes
- resource
type: object
SpantypesSpanMapperGroupConditionKey:
properties:
enabled:
type: boolean
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
value:
type: string
required:
- value
- enabled
type: object
SpantypesSpanMapperOperation:
enum:
- move
- copy
type: string
SpantypesSpanMapperOrigin:
enum:
- user
- system
type: string
SpantypesSpanMapperSource:
properties:
context:
$ref: '#/components/schemas/SpantypesFieldContext'
enabled:
type: boolean
key:
type: string
operation:
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
priority:
type: integer
required:
@@ -9798,6 +9924,7 @@ components:
- context
- operation
- priority
- enabled
type: object
SpantypesSpanMapperTestSpan:
properties:
@@ -20330,6 +20457,85 @@ paths:
summary: Update notification channel
tags:
- channels
/api/v2/notification_channels/{id}/repair:
post:
deprecated: false
description: 'This endpoint diagnoses a stored channel that the v2 API cannot
read and applies the fitting action: a channel carrying several notifier configurations
is split into one channel per configuration, keeping this ID for the first;
a channel whose notifier kind v2 does not model is deleted; a channel with
an empty stored type has it rewritten from its data. A delete is refused while
a routing policy still names the channel. Nothing is written unless apply=true;
by default the response only shows what would happen.'
operationId: RepairNotificationChannel
parameters:
- in: query
name: apply
schema:
type: boolean
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesRepairChannelParams'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesChannelRepair'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:update
- tokenizer:
- notification-channel:update
summary: Repair notification channel
tags:
- channels
/api/v2/notification_channels/test:
post:
deprecated: false

View File

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

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

View File

@@ -40,7 +40,73 @@ export interface AlertmanagertypesChannelDTO {
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfirmationDTO {
/**
* @type string
*/
dismissText?: string;
/**
* @type string
*/
okText?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelSlackActionDTO {
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
style?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
type: string;
/**
* @type string
*/
url?: string;
/**
* @type string
*/
value?: string;
}
export interface AlertmanagertypesChannelSlackFieldDTO {
/**
* @type boolean,null
*/
short?: boolean | null;
/**
* @type string
*/
title: string;
/**
* @type string
*/
value: string;
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
/**
* @type string
* @format password
@@ -50,6 +116,26 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
channel?: string;
/**
* @type string
*/
color?: string;
/**
* @type string
*/
fallback?: string;
/**
* @type array
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
/**
* @type string
*/
footer?: string;
/**
* @type string
*/
pretext?: string;
/**
* @type boolean,null
*/
@@ -62,6 +148,10 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
title?: string;
/**
* @type string
*/
titleLink?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
@@ -506,6 +596,13 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
missing_type = 'missing_type',
multiple_notifiers = 'multiple_notifiers',
unsupported_notifier = 'unsupported_notifier',
unrepresentable = 'unrepresentable',
}
export enum AlertmanagertypesChannelKindDTO {
slack = 'slack',
email = 'email',
@@ -527,6 +624,63 @@ export enum AlertmanagertypesChannelListSortDTO {
created_at = 'created_at',
name = 'name',
}
export enum AlertmanagertypesChannelRepairActionDTO {
none = 'none',
retype = 'retype',
split = 'split',
delete = 'delete',
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesChannelRepairDTO {
action: AlertmanagertypesChannelRepairActionDTO;
/**
* @type boolean
*/
applied: boolean;
/**
* @type array
*/
blockers?: string[];
/**
* @type array,null
*/
channels?: AlertmanagertypesListedNotificationChannelDTO[] | null;
defect: AlertmanagertypesChannelDefectDTO;
/**
* @type string
*/
detail?: string;
/**
* @type string
*/
id: string;
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -1020,32 +1174,6 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
wont_fix_resolution?: string;
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesListableNotificationChannelDTO {
/**
* @type array
@@ -2449,6 +2577,13 @@ export interface AlertmanagertypesReceiverDTO {
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AlertmanagertypesRepairChannelParamsDTO {
/**
* @type boolean
*/
apply?: boolean;
}
export interface AlertmanagertypesTestableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
@@ -4009,6 +4144,52 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
/**
* @minimum 0
* @maximum 1
* @nullable
*/
export type DashboardtypesFillOpacityDTO = number | null;
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesAreaChartAppearanceDTO {
fillMode?: DashboardtypesAreaFillModeDTO;
fillOpacity?: DashboardtypesFillOpacityDTO | null;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
lineStyle?: DashboardtypesLineStyleDTO;
/**
* @type boolean
*/
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -4086,6 +4267,11 @@ export interface DashboardtypesThresholdWithLabelDTO {
value: number;
}
export enum DashboardtypesStackModeDTO {
none = 'none',
normal = 'normal',
percent = 'percent',
}
export enum DashboardtypesTimePreferenceDTO {
global_time = 'global_time',
last_5_min = 'last_5_min',
@@ -4098,6 +4284,27 @@ export enum DashboardtypesTimePreferenceDTO {
last_1_week = 'last_1_week',
last_1_month = 'last_1_month',
}
export interface DashboardtypesAreaChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
stack?: DashboardtypesStackModeDTO;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesAreaChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -4795,29 +5002,6 @@ export enum DashboardtypesFillModeDTO {
gradient = 'gradient',
none = 'none',
}
export enum DashboardtypesLineInterpolationDTO {
linear = 'linear',
spline = 'spline',
step_after = 'step_after',
step_before = 'step_before',
}
export enum DashboardtypesLineStyleDTO {
solid = 'solid',
dashed = 'dashed',
}
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
fillMode?: DashboardtypesFillModeDTO;
lineInterpolation?: DashboardtypesLineInterpolationDTO;
@@ -4870,6 +5054,18 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBarChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
/**
* @enum signoz/AreaChartPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
spec: DashboardtypesAreaChartPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
'signoz/NumberPanel' = 'signoz/NumberPanel',
}
@@ -5072,93 +5268,16 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
export enum DashboardtypesHeatmapYScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesHeatmapAxesDTO {
yScale?: DashboardtypesHeatmapYScaleDTO;
}
export enum DashboardtypesHeatmapColorModeDTO {
palette = 'palette',
opacity = 'opacity',
}
export enum DashboardtypesHeatmapPaletteDTO {
ice = 'ice',
moss = 'moss',
rust = 'rust',
graphite = 'graphite',
ember = 'ember',
lagoon = 'lagoon',
orchid = 'orchid',
verdant = 'verdant',
lava = 'lava',
beacon = 'beacon',
}
export enum DashboardtypesHeatmapColorScaleDTO {
log = 'log',
sqrt = 'sqrt',
linear = 'linear',
}
export interface DashboardtypesHeatmapColorsDTO {
/**
* @type string
*/
fill?: string;
/**
* @type number,null
*/
maxCount?: number | null;
/**
* @type number,null
*/
minCount?: number | null;
mode?: DashboardtypesHeatmapColorModeDTO;
palette?: DashboardtypesHeatmapPaletteDTO;
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
*/
steps?: number;
}
export interface DashboardtypesHeatmapChartAppearanceDTO {
colors?: DashboardtypesHeatmapColorsDTO;
}
export interface DashboardtypesHeatmapPanelSpecDTO {
axes?: DashboardtypesHeatmapAxesDTO;
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
/**
* @enum signoz/HeatmapPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6077,13 +6196,13 @@ export interface DashboardtypesListableDashboardViewDTO {
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
'signoz/NumberPanel' = 'signoz/NumberPanel',
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable
@@ -10761,6 +10880,22 @@ export interface SpantypesGettableFlamegraphTraceDTO {
startTimestampMillis: number;
}
export enum SpantypesSpanMapperOriginDTO {
user = 'user',
system = 'system',
}
export interface SpantypesSpanMapperGroupConditionKeyDTO {
/**
* @type boolean
*/
enabled: boolean;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type string
*/
value: string;
}
/**
* @nullable
*/
@@ -10768,11 +10903,11 @@ export type SpantypesSpanMapperGroupConditionDTO = {
/**
* @type array,null
*/
attributes: string[] | null;
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
/**
* @type array,null
*/
resource: string[] | null;
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
} | null;
export interface SpantypesSpanMapperGroupDTO {
@@ -10802,6 +10937,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
orgId: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -10811,6 +10947,10 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
updatedBy?: string;
/**
* @type integer
*/
version: number;
}
export interface SpantypesGettableSpanMapperGroupsDTO {
@@ -10868,11 +11008,16 @@ export enum SpantypesSpanMapperOperationDTO {
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type boolean
*/
enabled: boolean;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type integer
*/
@@ -10914,6 +11059,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
*/
name: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -13473,6 +13619,25 @@ export type UpdateNotificationChannel200 = {
status: string;
};
export type RepairNotificationChannelPathParameters = {
id: string;
};
export type RepairNotificationChannelParams = {
/**
* @type boolean
* @description undefined
*/
apply?: boolean;
};
export type RepairNotificationChannel200 = {
data: AlertmanagertypesChannelRepairDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,21 +1,15 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldKeysConfig = GetFieldsKeysParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldValuesConfig = GetFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,

View File

@@ -198,10 +198,6 @@ function createBaseSpec(
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
// Heatmap only. Every other request type rejects an axis, and
// `panelTypeDataSourceFormValuesMap` is what keeps one from being carried onto a
// query the panel type switched away from.
bucketOptions: queryData.bucketOptions,
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import useActiveQueryIndex from './useActiveQueryIndex';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';

View File

@@ -56,6 +56,57 @@ export function mockFieldsValuesAPI(response: {
);
}
/**
* Records every request the AI observability values endpoint receives, so a test
* can assert both the routing and the query params it was called with.
*/
export function mockAIObservabilityFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
}): { requests: URLSearchParams[] } {
const requests: URLSearchParams[] = [];
server.use(
rest.get(
'http://localhost/api/v1/ai_observability/fields/values',
(req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
},
},
}),
);
},
),
);
return { requests };
}
/** Fails the test if the signal-wide values endpoint is hit at all. */
export function forbidFieldsValuesAPI(): { called: boolean } {
const state = { called: false };
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) => {
state.called = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
return state;
}
export function mockFieldsValuesAPILoading(): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>

View File

@@ -16,7 +16,7 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from '../useActiveQueryIndex';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';

View File

@@ -0,0 +1,81 @@
import { screen, waitFor } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
forbidFieldsValuesAPI,
mockAIObservabilityFieldsValuesAPI,
mockFieldsValuesAPI,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - AI observability routing', () => {
it('reads values from the AI observability endpoint and never the signal-wide one', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai', 'anthropic'],
});
const fieldsEndpoint = forbidFieldsValuesAPI();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('openai')).resolves.toBeInTheDocument();
expect(screen.getByText('anthropic')).toBeInTheDocument();
expect(fieldsEndpoint.called).toBe(false);
expect(aiEndpoint.requests).toHaveLength(1);
});
it('forwards the filter key and the time range to the AI observability endpoint', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByText('openai');
const params = aiEndpoint.requests[0];
expect(params.get('name')).toBe(DEFAULT_FILTER.attributeKey.key);
expect(params.get('startUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.startUnixMilli),
);
expect(params.get('endUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
);
});
it('keeps non-AI sources on the signal-wide endpoint', async () => {
mockFieldsValuesAPI({ stringValues: ['production'] });
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['should-not-be-used'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('production')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiEndpoint.requests).toHaveLength(0));
});
});

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldValuesConfig } from 'api/querySuggestions/types';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
interface UseFieldValuesProps {
@@ -42,32 +43,43 @@ export function useFieldValues({
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const { data, isLoading, isFetching } = useGetFieldsValues(
{
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
},
{
query: {
enabled,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
},
},
);
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
// The AI values endpoint is already gen_ai-scoped: no signal, no source.
const fieldValuesConfig: FieldValuesConfig = isAIObservability
? {
name: filter.attributeKey.key,
searchText,
existingQuery,
startUnixMilli,
endUnixMilli,
}
: {
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
};
const {
data: values,
isLoading,
isFetching,
} = useFieldValuesSuggestion(fieldValuesConfig, builderQueryType, { enabled });
const relatedValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -78,10 +90,9 @@ export function useFieldValues({
value !== null && value !== undefined && value !== '',
) || []
);
}, [data]);
}, [values]);
const allValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -101,7 +112,7 @@ export function useFieldValues({
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues, ...boolValues];
}, [data]);
}, [values]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Collapse } from 'antd';
import { Undo2 } from '@signozhq/icons';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -39,7 +39,7 @@ function Duration({
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
source?: QuickFiltersSource;
source: QuickFiltersSource;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] =
useState<
@@ -52,26 +52,11 @@ function Duration({
filter.defaultOpen ? 'durationNano' : '',
]);
const {
currentQuery,
redirectWithQueryBuilderData,
lastUsedQuery,
panelType,
} = useQueryBuilder();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
const activeQueryIndex = useActiveQueryIndex(source);
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {

View File

@@ -35,6 +35,7 @@ import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
@@ -113,14 +114,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// AI observability builds a single query in the row-level views, so there is
// no query for the selector to switch between.
const isAIObservabilityRowView =
source === QuickFiltersSource.AI_OBSERVABILITY &&
(isListView || panelType === PANEL_TYPES.TRACE);
const activeQueryIndex = useActiveQueryIndex(source);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
@@ -167,9 +167,10 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
// In ListView, always show the 0th query's name; otherwise use the active query's name
const displayedQueryName = isListView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const displayedQueryName =
isListView || isAIObservabilityRowView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const handleQueryChange = (value: number): void => {
setLastUsedQuery(value);
@@ -182,7 +183,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{queryOptions.length > 1 && (!isListView || shouldShowDropdownInListView) ? (
{queryOptions.length > 1 &&
!isAIObservabilityRowView &&
(!isListView || shouldShowDropdownInListView) ? (
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
placeholder="Select a query"
@@ -318,6 +321,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<Duration
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>

View File

@@ -1,12 +1,14 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig } from 'api/querySuggestions/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
FieldDataType,
TelemetryFieldKey,
@@ -41,23 +43,31 @@ function OtherFilters({
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
const fieldKeysConfig: FieldKeysConfig = isAIObservability
? { searchText: inputValue }
: {
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
};
const { data: fetchedKeys, isFetching } = useFieldKeysSuggestion(
fieldKeysConfig,
builderQueryType,
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
const suggestions: TelemetryFieldKey[] = (fetchedKeys ?? []).map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
@@ -71,7 +81,7 @@ function OtherFilters({
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
}, [fetchedKeys, addedFilters]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);

View File

@@ -0,0 +1,81 @@
import { screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render } from 'tests/test-utils';
import { SignalType } from '../../types';
import OtherFilters from '../OtherFilters';
const BASE_URL = ENVIRONMENT.baseURL;
const FIELDS_KEYS_URL = `${BASE_URL}/api/v1/fields/keys`;
const AI_KEYS_URL = `${BASE_URL}/api/v1/ai_observability/fields/keys`;
function keysResponse(name: string): Record<string, unknown> {
return {
status: 'success',
data: {
complete: true,
keys: {
[name]: [{ name, fieldContext: 'attribute', fieldDataType: 'string' }],
},
},
};
}
describe('OtherFilters - AI observability keys', () => {
let fieldsKeysCalled: boolean;
let aiKeysParams: URLSearchParams | undefined;
beforeEach(() => {
fieldsKeysCalled = false;
aiKeysParams = undefined;
server.use(
rest.get(FIELDS_KEYS_URL, (_, res, ctx) => {
fieldsKeysCalled = true;
return res(ctx.status(200), ctx.json(keysResponse('http.route')));
}),
rest.get(AI_KEYS_URL, (req, res, ctx) => {
aiKeysParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(keysResponse('gen_ai.request.model')));
}),
);
});
function renderOtherFilters(signal: SignalType): void {
render(
<OtherFilters
signal={signal}
inputValue=""
addedFilters={[]}
setAddedFilters={jest.fn()}
/>,
);
}
it('reads AI observability keys from their own endpoint', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
await expect(
screen.findByText('gen_ai.request.model'),
).resolves.toBeInTheDocument();
expect(fieldsKeysCalled).toBe(false);
});
it('does not narrow the AI keys by fieldContext', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
// A `trace` context would return only the computed per-trace aggregates,
// which cannot be filtered on.
await waitFor(() => expect(aiKeysParams).toBeDefined());
expect(aiKeysParams?.get('fieldContext')).toBeNull();
});
it('keeps other signals on the signal-wide keys endpoint', async () => {
renderOtherFilters(SignalType.TRACES);
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiKeysParams).toBeUndefined());
});
});

View File

@@ -7,4 +7,5 @@ export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
[SignalType.METER_EXPLORER]: DataSource.METRICS,
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
};

View File

@@ -0,0 +1,81 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { QuickFiltersSource } from '../../types';
import useActiveQueryIndex from '../useActiveQueryIndex';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const LAST_USED_QUERY = 2;
function mockQueryBuilder(panelType: PANEL_TYPES): void {
(useQueryBuilder as jest.Mock).mockReturnValue({
lastUsedQuery: LAST_USED_QUERY,
panelType,
});
}
describe('useActiveQueryIndex', () => {
describe('AI observability builds a single query in the row-level views', () => {
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'drives the first query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(0);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'follows the last used query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(LAST_USED_QUERY);
},
);
});
describe('other sources are unchanged', () => {
it('lets the traces explorer track the last used query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.TRACES_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
it('pins single-query sources to the first query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.INFRA_MONITORING),
);
expect(result.current).toBe(0);
});
it('tracks the last used query outside list view', () => {
mockQueryBuilder(PANEL_TYPES.TIME_SERIES);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.LOGS_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
});
});

View File

@@ -15,13 +15,21 @@ function useActiveQueryIndex(source: QuickFiltersSource): number {
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
// AI observability builds a single query in the row-level views, so its
// filters always drive the first one there.
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
return isListView || panelType === PANEL_TYPES.TRACE
? 0
: lastUsedQuery || 0;
}
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}, [isListView, panelType, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -24,6 +24,7 @@ export enum SignalType {
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai_observability',
}
/**
@@ -69,6 +70,7 @@ export enum QuickFiltersSource {
API_MONITORING = 'api-monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai-observability',
}
/**

View File

@@ -29,10 +29,9 @@ export const getComponentForPanelType = (
[PANEL_TYPES.LIST]:
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.AREA]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,

View File

@@ -336,9 +336,9 @@ export enum PANEL_TYPES {
LIST = 'list',
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

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

View File

@@ -109,6 +109,9 @@ export const REACT_QUERY_KEY = {
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// Field Values Suggestion Query Keys
FIELD_VALUES_SUGGESTION: 'FIELD_VALUES_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
} as const;

View File

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

View File

@@ -82,7 +82,7 @@ export function getHostMetricsQueryPayload(
start: number,
end: number,
): ReturnType<typeof getHostQueryPayload> {
return getHostQueryPayload(host.hostName, start, end, true);
return getHostQueryPayload(host.hostName, start, end);
}
export { hostWidgetInfo };

View File

@@ -333,6 +333,7 @@ describe('AttributeMappingsTab (integration)', () => {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
enabled: true,
})),
},
}),

View File

@@ -1,10 +1,12 @@
import { Typography } from '@signozhq/ui/typography';
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
import styles from './ConditionsTooltip.module.scss';
interface ConditionsTooltipProps {
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
}
function ConditionsTooltip({
@@ -33,8 +35,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{attributes.map((key) => (
<code key={key} className={styles.key}>
{key}
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
</code>
))}
</div>
@@ -47,8 +49,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{resource.map((key) => (
<code key={key} className={styles.key}>
{key}
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
</code>
))}
</div>

View File

@@ -3,6 +3,7 @@ import {
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
SpantypesSpanMapperOriginDTO as MapperOrigin,
SpantypesSpanMapperTestSpanDTO as TestSpan,
} from 'api/generated/services/sigNoz.schemas';
@@ -21,9 +22,15 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
orgId: 'org-1',
name: 'demo',
enabled: true,
origin: MapperOrigin.user,
version: 0,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
},
...overrides,
};
@@ -35,6 +42,7 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
groupId: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
origin: MapperOrigin.user,
fieldContext: FieldContext.attribute,
config: {
sources: [
@@ -43,12 +51,16 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
enabled: true,
origin: MapperOrigin.user,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
enabled: true,
origin: MapperOrigin.user,
},
],
},
@@ -85,8 +97,12 @@ export const mockGroups: MapperGroup[] = [
id: 'group-1',
name: 'demo',
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
},
}),
makeGroup({

View File

@@ -1,19 +1,23 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import {
ConditionKey,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
keys: ConditionKey[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
onChange: (keys: ConditionKey[]) => void;
}
function ConditionKeyList({
@@ -27,11 +31,11 @@ function ConditionKeyList({
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
onChange([...keys, createConditionKey()]);
};
const removeKey = (index: number): void => {
@@ -53,7 +57,7 @@ function ConditionKeyList({
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key}
value={key.value}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}

View File

@@ -42,7 +42,9 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
source.operation === b[index].operation &&
source.enabled === b[index].enabled &&
source.origin === b[index].origin,
)
);
}

View File

@@ -1,8 +1,11 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperOriginDTO,
SpantypesSpanMapperSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
@@ -11,21 +14,23 @@ export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
export type ConditionKey = SpantypesSpanMapperGroupConditionKeyDTO;
export type MapperDraftMode = 'add' | 'edit';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// `priority` is left out: it is derived from list order when the draft is
// serialized.
export type SourceConfig = Omit<SpantypesSpanMapperSourceDTO, 'priority'>;
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
fieldContext: FieldContextValue;
sources: SourceConfig[];
enabled: boolean;
}
@@ -33,26 +38,20 @@ export interface MapperDraft {
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
enabled: boolean;
}
export interface DraftMapper {
// The editor tree identifies rows by `localId` so unsaved ones are addressable;
// `serverId` is null until the row has been persisted.
export type DraftMapper = Omit<MapperDraft, 'id'> & {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
};
export interface DraftGroup {
export type DraftGroup = Omit<GroupDraft, 'id'> & {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}
};

View File

@@ -1,12 +1,14 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionKey,
DraftGroup,
DraftMapper,
FieldContext,
@@ -15,6 +17,7 @@ import {
MapperDraft,
MapperGroup,
MapperOperation,
MapperOrigin,
SourceConfig,
} from './types';
@@ -24,20 +27,36 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
function cleanKeys(keys: string[]): string[] {
export function createConditionKey(value = ''): ConditionKey {
return { value, enabled: true, origin: MapperOrigin.user };
}
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
// a user key may share a value, so the origin is part of the identity.
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
const seen = new Set<string>();
const result: string[] = [];
const result: ConditionKey[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
const value = raw.value.trim();
const dedupeKey = `${raw.origin}:${value}`;
if (value && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...raw, value });
}
});
return result;
}
function fromConditionKeys(
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
): ConditionKey[] {
return (keys ?? []).map((key) => ({
value: key.value,
enabled: key.enabled,
origin: key.origin ?? MapperOrigin.user,
}));
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
function getMapperSources(mapper: Mapper): SourceConfig[] {
@@ -48,6 +67,8 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
key: source.key,
context: source.context,
operation: source.operation,
enabled: source.enabled,
origin: source.origin ?? MapperOrigin.user,
}));
}
@@ -56,6 +77,8 @@ export function createEmptySource(): SourceConfig {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
enabled: true,
origin: MapperOrigin.user,
};
}
@@ -72,7 +95,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
const dedupeKey = `${source.origin}:${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
@@ -95,6 +118,8 @@ function buildSources(
context: source.context,
operation: source.operation,
priority: sources.length - index,
enabled: source.enabled,
origin: source.origin,
}));
}
@@ -123,7 +148,7 @@ export function buildUpdatableMapper(
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
attributes: [createConditionKey()],
resource: [],
enabled: true,
};
@@ -170,8 +195,8 @@ export function buildDraftGroup(
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
attributes: fromConditionKeys(group.condition?.attributes),
resource: fromConditionKeys(group.condition?.resource),
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
@@ -182,7 +207,8 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
attributes:
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
resource: group.resource,
enabled: group.enabled,
};

View File

@@ -8,18 +8,13 @@ import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -28,7 +23,6 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -37,7 +31,7 @@ import {
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from 'pages/TracesExplorer/aiActions';
} from './aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -45,12 +39,10 @@ import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
@@ -60,7 +52,6 @@ import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
@@ -72,20 +63,12 @@ function Explorer(): JSX.Element {
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -112,19 +95,24 @@ function Explorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const { startUnixMilli, endUnixMilli } = useSignalFieldApis();
// existingQuery is left unset so related values auto-extract from the current query
const quickFiltersFieldApis = useMemo(
() => ({ startUnixMilli, endUnixMilli }),
[startUnixMilli, endUnixMilli],
);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
PANEL_TYPES.LIST,
DEFAULT_PANEL_TYPE,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -139,7 +127,7 @@ function Explorer(): JSX.Element {
},
[handleExplorerTabChange, handleSetConfig],
);
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
@@ -179,59 +167,6 @@ function Explorer(): JSX.Element {
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
@@ -260,8 +195,9 @@ function Explorer(): JSX.Element {
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
@@ -354,14 +290,6 @@ function Explorer(): JSX.Element {
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</Sentry.ErrorBoundary>

View File

@@ -12,25 +12,17 @@ import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -42,6 +34,7 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
@@ -79,14 +72,6 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -98,19 +83,6 @@ function ListView({
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -120,7 +92,6 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
@@ -128,7 +99,6 @@ function ListView({
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
@@ -150,7 +120,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
selectColumns: defaultSelectedColumns,
},
},
ENTITY_VERSION_V5,
@@ -158,10 +128,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);
@@ -186,28 +153,20 @@ function ListView({
[queryTableDataResult],
);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
[],
);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
@@ -235,15 +194,9 @@ function ListView({
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
@@ -251,6 +204,8 @@ function ListView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -258,8 +213,6 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);

View File

@@ -1,19 +1,41 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;
export const defaultSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'name',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
},
{
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
signal: 'traces',
fieldContext: 'span',
},
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,47 +1,8 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
@@ -60,95 +21,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.

View File

@@ -4,8 +4,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const isRawQuery = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,

View File

@@ -107,7 +107,7 @@ function TableView({
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
fileName="ai-traces-table"
/>
</div>
)}

View File

@@ -126,6 +126,7 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
exportFileName="ai-traces-timeseries"
/>
</div>
);

View File

@@ -55,6 +55,9 @@ function TracesTable({
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
// Rows can land before the field keys, and mounting then renders a partial column set.
const canMountTable = !isError && !isLoading && data.length !== 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
@@ -83,7 +86,7 @@ function TracesTable({
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
{canMountTable && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { buildTraceViewColumns } from '../../TracesView/configs';
import TracesTable from '../TracesTable';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);
return (
<>
<button type="button" onClick={(): void => setColumnsReady(true)}>
columns-ready
</button>
<TracesTable
data={ROWS}
columns={columnsReady ? COLUMNS : []}
columnStorageKey={STORAGE_KEY}
respectColumnOrder
panelType="TRACE"
getRowHref={(): string => '/trace/abc'}
isLoading={!columnsReady}
isFetching={false}
isError={false}
error={null}
isFilterApplied={false}
/>
</>
);
}
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
describe('TracesTable column-init race', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
it('does not persist empty defaults when rows land before columns', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RaceHarness />);
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -1,6 +1,13 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
export const TIMESTAMP_FIELD_NAMES = new Set([
'timestamp',
'start_time',
'end_time',
'last_activity_time',
]);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
@@ -13,6 +20,12 @@ export const STATUS_FIELD_NAMES = new Set([
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
export const DURATION_FIELD_NAMES = new Set([
'durationNano',
'duration_nano',
'trace_duration_nano',
'max_llm_duration_nano',
]);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -67,6 +67,7 @@ function TracesView({
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const {
@@ -168,9 +169,22 @@ function TracesView({
setOrderBy(value);
}, []);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
@@ -207,8 +221,12 @@ function TracesView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}

View File

@@ -0,0 +1,190 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
const OPTIONS_TRIGGER = 'options_menu.options';
const ROWS = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'checkout',
root_span_name: 'HTTP GET',
trace_duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
];
const mockRows = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'trace',
data: { results: [{ queryName: 'A', rows: ROWS }] },
},
}),
),
),
);
};
const mockFieldKeys = (names: string[]): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
),
),
);
};
const mockFieldKeysFailure = (): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView column persistence', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
mockRows();
});
afterEach(() => {
server.resetHandlers();
});
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('persists nothing when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
});
});

View File

@@ -149,6 +149,62 @@ describe('useTraceViewColumns', () => {
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();

View File

@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',

View File

@@ -35,11 +35,17 @@ interface UseTraceViewColumns {
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
@@ -60,10 +66,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
@@ -83,6 +89,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
@@ -96,7 +106,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns],
[columns, isSuccess],
);
return {
@@ -105,5 +115,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -1,7 +1,17 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
list: {
name: 'list',
label: 'List',
@@ -15,13 +25,6 @@ export const TOOLBAR_VIEWS = {
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',

View File

@@ -1,6 +1,5 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
@@ -31,31 +30,3 @@ export const getListViewQuery = (
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -1,19 +1,21 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
import './ToolbarActions.styles.scss';
interface ToolbarViewItem {
name: string;
key: string;
show?: boolean;
disabled?: boolean;
}
interface LeftToolbarActionsProps {
items: any;
items: Record<string, ToolbarViewItem>;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
@@ -29,8 +31,6 @@ export default function LeftToolbarActions({
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
@@ -41,91 +41,34 @@ export default function LeftToolbarActions({
</Button>
</Tooltip>
)}
{/* Buttons render in the order the caller declares its views. */}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{Object.values(items).map((item) => {
const config = TOOLBAR_VIEW_CONFIG[item?.key];
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
if (!item?.show || !config) {
return null;
}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
const { icon: Icon, label, className, testId } = config;
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
return (
<Tooltip key={item.key} title={label}>
<Button
disabled={item.disabled}
className={cx(
className,
'explorer-view-option',
selectedView === item.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
>
<Icon size={14} data-testid={testId} />
{label}
</Button>
</Tooltip>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,47 @@
import {
Atom,
Binoculars,
SquareMousePointer,
Terminal,
} from '@signozhq/icons';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
export interface ToolbarViewConfig {
icon: typeof Atom;
label: string;
className: string;
testId: string;
}
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
[ExplorerViews.LIST]: {
icon: SquareMousePointer,
label: 'List View',
className: 'list-view-tab',
testId: 'search-view',
},
[ExplorerViews.TRACE]: {
icon: SquareMousePointer,
label: 'Trace View',
className: 'trace-view-tab',
testId: 'trace-view',
},
[ExplorerViews.TIMESERIES]: {
icon: Atom,
label: 'Time Series',
className: 'timeseries-view-tab',
testId: 'query-builder-view',
},
[ExplorerViews.CLICKHOUSE]: {
icon: Terminal,
label: 'Clickhouse',
className: 'clickhouse-view-tab',
testId: 'clickhouse-view',
},
[ExplorerViews.TABLE]: {
icon: Binoculars,
label: 'Table',
className: 'table-view-tab',
testId: 'query-builder-view-v2',
},
};

View File

@@ -28,7 +28,7 @@ function InfraMetrics({
dataSource = DataSource.LOGS,
}: MetricsDataProps): JSX.Element {
const [selectedView, setSelectedView] = useState<string>(() =>
podName ? VIEW_TYPES.POD : VIEW_TYPES.NODE,
nodeName || hostName ? VIEW_TYPES.NODE : VIEW_TYPES.POD,
);
const viewOptions = useMemo(() => {
@@ -60,6 +60,10 @@ function InfraMetrics({
}, [podName]);
const handleModeChange = (value: string): void => {
// single toggle-group emits '' on re-click of the pressed item
if (!value) {
return;
}
setSelectedView(value);
};

View File

@@ -4,9 +4,8 @@ import { Card, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { ENTITY_VERSION_V5 } from 'constants/app';
import dayjs from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
@@ -61,9 +60,9 @@ function NodeMetrics({
const widgetInfo = nodeName ? nodeWidgetInfo : hostWidgetInfo;
const queries = useQueries(
queryPayloads.map((payload) => ({
queryKey: ['metrics', payload, ENTITY_VERSION_V4, 'NODE'],
queryKey: ['metrics', payload, ENTITY_VERSION_V5, 'NODE'],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
})),
);
@@ -85,7 +84,6 @@ function NodeMetrics({
);
const { timezone } = useTimezone();
const { currentQuery } = useQueryBuilder();
const options = useMemo(
() =>
@@ -103,7 +101,7 @@ function NodeMetrics({
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
query: currentQuery,
query: queryPayloads[idx].query,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
@@ -122,7 +120,7 @@ function NodeMetrics({
verticalLineTimestamp,
end,
timezone.value,
currentQuery,
queryPayloads,
],
);

View File

@@ -4,9 +4,8 @@ import { Card, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { ENTITY_VERSION_V5 } from 'constants/app';
import dayjs from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
@@ -58,9 +57,9 @@ function PodMetrics({
);
const queries = useQueries(
queryPayloads.map((payload) => ({
queryKey: ['metrics', payload, ENTITY_VERSION_V4, 'POD'],
queryKey: ['metrics', payload, ENTITY_VERSION_V5, 'POD'],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
})),
);
@@ -74,7 +73,6 @@ function PodMetrics({
[queries],
);
const { timezone } = useTimezone();
const { currentQuery } = useQueryBuilder();
const options = useMemo(
() =>
@@ -92,7 +90,7 @@ function PodMetrics({
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
query: currentQuery,
query: queryPayloads[idx].query,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
@@ -110,7 +108,7 @@ function PodMetrics({
end,
verticalLineTimestamp,
timezone.value,
currentQuery,
queryPayloads,
],
);

View File

@@ -1,18 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import type { Having } from 'types/api/queryBuilder/queryBuilderData';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { Having as HavingV5 } from 'types/api/v5/queryRange';
import type { Having } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
const buildSumGreaterThanZeroHaving = (
metricKey: string,
useV5HavingFormat: boolean,
): Having[] | HavingV5 =>
useV5HavingFormat
? { expression: `sum(${metricKey}) > 0` }
: [{ columnName: `SUM(${metricKey})`, op: '>', value: 0 }];
const buildSumGreaterThanZeroHaving = (metricKey: string): Having => ({
expression: `sum(${metricKey}) > 0`,
});
export const getPodQueryPayload = (
clusterName: string,
@@ -1550,7 +1545,6 @@ export const getHostQueryPayload = (
hostName: string,
start: number,
end: number,
useV5HavingFormat = false,
): GetQueryResultsProps[] => {
const hostNameKey = 'host.name';
const cpuTimeKey = 'system.cpu.time';
@@ -1813,7 +1807,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(fsUsageKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(fsUsageKey),
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
@@ -1862,7 +1856,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(fsUsageKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(fsUsageKey),
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
@@ -2088,7 +2082,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(netIoKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(netIoKey),
legend: '{{device}}::{{direction}}',
limit: 30,
orderBy: [],
@@ -2544,7 +2538,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(diskOpsKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(diskOpsKey),
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],
@@ -2613,7 +2607,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(diskPendingKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(diskPendingKey),
legend: '{{device}}',
limit: null,
orderBy: [],
@@ -2689,7 +2683,7 @@ export const getHostQueryPayload = (
type: 'tag',
},
],
having: buildSumGreaterThanZeroHaving(diskOpTimeKey, useV5HavingFormat),
having: buildSumGreaterThanZeroHaving(diskOpTimeKey),
legend: '{{device}}::{{direction}}',
limit: null,
orderBy: [],

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ function TimeSeriesView({
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
exportFileName,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -270,7 +271,7 @@ function TimeSeriesView({
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={`${dataSource}-timeseries`}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
@@ -339,6 +340,7 @@ interface TimeSeriesViewProps {
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
exportFileName?: string;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
@@ -351,6 +353,7 @@ TimeSeriesView.defaultProps = {
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
exportFileName: undefined,
};
export default TimeSeriesView;

View File

@@ -27,9 +27,9 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
LIST: false,
TRACE: false,
BAR: true,
AREA: true,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import {
RenderErrorResponseDTO,
TelemetrytypesTelemetryFieldValuesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
FieldValuesConfig,
FieldValuesResponse,
} from 'api/querySuggestions/types';
import { BuilderQueryType } from 'types/api/v5/queryRange';
export type FieldValuesQueryOptions = UseQueryOptions<
FieldValuesResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetrytypesTelemetryFieldValuesDTO
> & { queryKey: QueryKey };
const EMPTY_FIELD_VALUES: TelemetrytypesTelemetryFieldValuesDTO = {};
export const toFieldValues = (
res: FieldValuesResponse | undefined,
): TelemetrytypesTelemetryFieldValuesDTO =>
res?.data?.values ?? EMPTY_FIELD_VALUES;
export const getFieldValuesQueryOptions = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
): FieldValuesQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_VALUES_SUGGESTION,
builderQueryType,
fieldValuesConfig,
],
queryFn: ({ signal }): Promise<FieldValuesResponse> =>
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, signal),
select: toFieldValues,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
});
export const useFieldValuesSuggestion = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
options?: Pick<FieldValuesQueryOptions, 'enabled'>,
): UseQueryResult<
TelemetrytypesTelemetryFieldValuesDTO,
ErrorType<RenderErrorResponseDTO>
> =>
useQuery({
...getFieldValuesQueryOptions(fieldValuesConfig, builderQueryType),
...options,
});

View File

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

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
getMetricUnits,
useGetMetrics,
@@ -46,6 +46,7 @@ function useGetYAxisUnit(
},
): UseGetYAxisUnitResult {
const { stagedQuery } = useQueryBuilder();
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const metricNames: string[] | null = useMemo(() => {
// If the query type is not QUERY_BUILDER, return null
@@ -94,16 +95,27 @@ function useGetYAxisUnit(
[units],
);
// Derived, not stored: `useGetMetrics` rebuilds its array on every render, so a
// state-and-effect version schedules an update after every render — the shape
// React reports as "Maximum update depth exceeded".
const yAxisUnit = useMemo(() => {
// A single shared unit is the only thing a single axis can carry; metrics that
// disagree, or that carry no unit at all, leave the axis unitless.
if (units.length === 0 || !areAllMetricUnitsSame) {
return undefined;
useEffect(() => {
// If there are no metrics, set the y-axis unit to undefined
if (units.length === 0) {
setYAxisUnit(undefined);
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
// If all metrics have the same non-empty unit, set the y-axis unit to it
} else if (areAllMetricUnitsSame) {
if (units[0] !== '') {
setYAxisUnit(units[0]);
} else {
setYAxisUnit(undefined);
}
// If there is more than one metric and they have different units, set the y-axis unit to undefined
} else if (units.length > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
} else if (units.length === 1 && units[0] === '') {
setYAxisUnit(undefined);
}
return units[0] || undefined;
}, [units, areAllMetricUnitsSame]);
return { yAxisUnit, isLoading, isError };

View File

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

View File

@@ -8,11 +8,7 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
// Asks "would saving the current panel change the persisted widget spec?".
@@ -103,7 +99,6 @@ export type PartialPanelTypes = {
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
export const panelTypeDataSourceFormValuesMap: Record<
@@ -311,75 +306,6 @@ export const panelTypeDataSourceFormValuesMap: Record<
},
},
},
// `functions` and `having` are dropped rather than carried: the heatmap request
// rejects both. Every signal is listed because the map is keyed by the query's
// own, which a switch can still be holding.
[PANEL_TYPES.HEATMAP]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
'bucketOptions',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {
@@ -612,24 +538,6 @@ export const panelTypeDataSourceFormValuesMap: Record<
},
};
/**
* Formulas are carried across a panel-type switch whole, not rebuilt from
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
* dropped by hand. The request rejects one on any type but heatmap.
*/
const withoutNonHeatmapBucketOptions = (
formulas: IBuilderFormula[],
newPanelType: keyof PartialPanelTypes,
): IBuilderFormula[] => {
if (newPanelType === PANEL_TYPES.HEATMAP) {
return formulas;
}
return (formulas ?? []).map(
({ bucketOptions: _bucketOptions, ...rest }) => rest,
);
};
export function handleQueryChange(
newPanelType: keyof PartialPanelTypes,
supersetQuery: Query,
@@ -673,10 +581,6 @@ export function handleQueryChange(
return tempQuery;
}),
queryFormulas: withoutNonHeatmapBucketOptions(
supersetQuery.builder.queryFormulas,
newPanelType,
),
queryTraceOperator:
newPanelType === PANEL_TYPES.LIST
? []

View File

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

View File

@@ -1,76 +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 the one state a ramp cannot express: a hatched data gap. */
showNoDataKey?: boolean;
'data-testid'?: string;
}
/** What a colour means, plus a marker for the value under the cursor. */
export default function ColorBar({
ramp,
minLabel,
maxLabel,
markerPosition = null,
label,
showNoDataKey = false,
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const gradient = useMemo(() => {
if (ramp.length === 0) {
return undefined;
}
if (ramp.length === 1) {
return ramp[0];
}
const stops = ramp.flatMap((color, index) => {
const from = (index / ramp.length) * 100;
const to = ((index + 1) / ramp.length) * 100;
return [`${color} ${from}%`, `${color} ${to}%`];
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [ramp]);
if (gradient === undefined) {
return null;
}
const clampedMarker =
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
return (
<div className={Styles.container} data-testid={testId}>
{label && <span className={Styles.caption}>{label}</span>}
<span className={Styles.label}>{minLabel}</span>
<div className={Styles.track} style={{ background: gradient }}>
{clampedMarker !== null && (
<span
className={Styles.marker}
style={{ left: `${clampedMarker * 100}%` }}
data-testid={`${testId}-marker`}
/>
)}
</div>
<span className={Styles.label}>{maxLabel}</span>
{showNoDataKey && (
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
<span className={Styles.key}>
<span className={Styles.hatchSwatch} />
no data
</span>
</div>
)}
</div>
);
}

View File

@@ -1,85 +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 one state a colour ramp cannot express', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showNoDataKey={true} />,
);
expect(screen.getByText('no data')).toBeInTheDocument();
});
it('leaves the no-data key out unless the data has a gap', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByText('no data')).not.toBeInTheDocument();
});
it('captions what the colour encodes', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
expect(screen.getByText('count')).toBeInTheDocument();
});
it('renders hard-edged segments so the bar matches the drawn cells', () => {
render(
<ColorBar
ramp={['#111111', '#dddddd']}
minLabel="0"
maxLabel="10"
data-testid="scale"
/>,
);
const track = screen.getByTestId('scale').querySelector('div');
expect(track).toHaveStyle({
background:
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
});
});
});

View File

@@ -2,6 +2,8 @@
.container {
position: relative;
// Contains the rows' z-indexes, which otherwise paint over side panels.
isolation: isolate;
display: flex;
flex-direction: column;
height: 100%;

View File

@@ -1,30 +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((bucket) => (
<div
key={bucket.row}
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
data-hovered={bucket.isHovered}
data-testid="heatmap-tooltip-bucket-row"
>
<span className={Styles.rowLabel}>{bucket.label}</span>
<span className={Styles.rowSeparator} />
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
</div>
))}
</div>
);
}

View File

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

View File

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

View File

@@ -1,180 +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 TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import HeatmapBucketList from './HeatmapBucketList';
import HeatmapContributionList from './HeatmapContributionList';
import {
buildBucketRows,
buildContributionRows,
formatBucketLabel,
formatColumnRange,
formatCount,
HeatmapTooltipBody,
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 = isolated?.label ?? '';
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="heatmap-tooltip"
>
<div className={Styles.identity}>
<div className={Styles.header}>
<span data-testid="heatmap-tooltip-range">
{formatColumnRange({
start: cell.timestamp,
step,
timezone: resolvedTimezone,
})}
</span>
{isPinned && <TooltipPinnedBadge />}
</div>
{filterLabel && (
<div
className={Styles.filter}
style={{ color: groupColor }}
data-testid="heatmap-tooltip-filter"
>
<span className={Styles.filterMarker} />
<span className={Styles.filterLabel}>{filterLabel}</span>
</div>
)}
<div className={Styles.title}>
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
{formatBucketLabel({
yAxis,
row: cell.row,
yAxisUnit,
decimalPrecision,
})}
</span>
<span className={Styles.titleSeparator} />
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
{formatCount(cell.count)}
</span>
</div>
</div>
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
{body === HeatmapTooltipBody.Contribution ? (
<HeatmapContributionList rows={contributionRows} />
) : (
<HeatmapBucketList rows={bucketRows} />
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@
.uplotTooltipItemContent {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: var(--spacing-2);
@@ -40,16 +41,24 @@
}
.uplotTooltipItemLabel {
min-width: 0;
// Not `anywhere`, which drops min-content to one character and lets a wide
// value squeeze the label into a mid-word break.
white-space: normal;
overflow-wrap: anywhere;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.uplotTooltipItemValue {
white-space: nowrap;
flex: 0 0 auto;
}
.uplotTooltipItemContentSeparator {
flex: 1;
flex: 1 1 24px;
border-width: 0.5px;
border-style: dashed;
min-width: 24px;

View File

@@ -34,7 +34,9 @@ export default function TooltipItem({
style={{ color: item.color }}
data-testid={contentTestId}
>
<span className={Styles.uplotTooltipItemLabel}>{item.label}</span>
<span className={Styles.uplotTooltipItemLabel} title={item.label}>
{item.label}
</span>
<span
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}

View File

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

View File

@@ -1,12 +0,0 @@
import { Pin } from '@signozhq/icons';
import Styles from './TooltipPinnedBadge.module.scss';
export default function TooltipPinnedBadge(): JSX.Element {
return (
<div className={Styles.status} data-testid="uplot-tooltip-status">
<Pin size={12} />
<span>Pinned</span>
</div>
);
}

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

@@ -3,6 +3,7 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
import { generateGradientFill } from '../utils/generateGradientFill';
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
import {
@@ -58,7 +59,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}: {
resolvedLineColor: string;
}): Partial<Series> {
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
const { lineWidth, lineStyle, lineCap, fillColor, fillMode, fillOpacity } =
this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? DEFAULT_LINE_WIDTH,
@@ -86,11 +88,17 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
lineConfig.fill = `${finalFillColor}70`;
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolvedOpacity)}`;
} else if (fillMode === FillMode.Gradient) {
lineConfig.fill = (self: uPlot): CanvasGradient =>
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
generateGradientFill(
self,
finalFillColor,
'rgba(0, 0, 0, 0)',
resolvedOpacity,
);
}
}

View File

@@ -3,7 +3,7 @@ import uPlot from 'uplot';
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
import type { SeriesProps } from '../types';
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
const createBaseProps = (
@@ -362,4 +362,40 @@ describe('UPlotSeriesBuilder', () => {
expect(config.points?.filter).toBeUndefined();
expect(config.points?.show).toBe(true);
});
// Pins the alpha the solid fill hardcoded before opacity was configurable.
it('fills a solid series at the default opacity', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
fillColor: '#112233',
fillMode: FillMode.Solid,
}),
);
expect(builder.getConfig().fill).toBe('#11223370');
});
it('fills a solid series at the declared opacity', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
fillColor: '#112233',
fillMode: FillMode.Solid,
fillOpacity: 0.5,
}),
);
expect(builder.getConfig().fill).toBe('#11223380');
});
it('leaves an unfilled series without a fill', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
fillColor: '#112233',
fillMode: FillMode.None,
fillOpacity: 0.5,
}),
);
expect(builder.getConfig().fill).toBeUndefined();
});
});

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