Compare commits

...

12 Commits

Author SHA1 Message Date
Aditya Singh
c14d256e6f feat(traces-explorer): track how users open the trace details page (#13420)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- A row click in the traces List view now opens the span drawer. We need
to know how users still reach the trace details page.
- The explorer now logs `Traces Explorer: Open trace clicked` on each
click that opens the trace details page.
- The event has two attributes:
- `panelType`: `list` or `trace`. These are the same values as `Traces
Explorer: Data present`.
  - `source`: `row`, `row_icon`, `trace_id` or `span_drawer`.

#### Issues closed by this PR

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

#### Additional Information

- Stacked on #13411.
- The event does not fire when a user opens the trace_id link from the
right-click menu. The browser does not tell the page which menu item the
user picks.
- Entry points outside the traces explorer do not log this event.
2026-11-06 05:15:33 +00:00
Gaurav Tewari
9a541c851f feat:ai o11y saved views (#13297)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds saved views to the AI observability explorer.
- New `ai_observability` preference slice. Columns are stored per panel
in localStorage (`AI_OBSERVABILITY_OPTIONS`); the slice holds only the
open panel's `{ columns, panelType }`. Only the Traces tab keeps columns
today.
- The Traces tab reads columns through `useOptionsMenu`, like the logs
and traces lists. Defaults are a fixed list
(`TRACE_VIEW_DEFAULT_COLUMNS`) matching the backend's trace aggregates,
so the table no longer waits for the keys API.
- The URL carries the open panel's columns in `options.selectColumns`;
logs and traces ignore it on the AI explorer route.
- Fixes saved views opening on the wrong tab: the explorer now reads the
tab from the URL For AI View

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

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



https://github.com/user-attachments/assets/daab1db3-b8df-435a-8252-d6bb35a86e99


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

- Review commit by commit: preferences → saved views → trace view →
explorer wiring → tab fix → discard and loading fixes. Each commit
builds.
- Columns picked on the Traces tab before this PR were kept in the
table's column store; they are ignored now, so those users see the
defaults once. Column widths are kept.
- The AI Explorer story is not updated in this PR.
- SB Shot -
https://claude.ai/artifact/R77CFZKS71pSxNLV4ZhpxY?sk=dM6jybUP456zVTcW9xrz8Q
- Check to have a summary of comment raised and closed with greptile
https://github.com/SigNoz/signoz/pull/13297#issuecomment-6508458226

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 16:53:57 +00:00
Nikhil Mantri
e0802b8274 feat(authz): rule state history FGA (#13252)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Move `/api/v2/rules/{id}/history/*` routes from role gates to
`CheckResources`.
- `stats` and `overall_status` check `rule:read` on the id.
- `timeline` and `top_contributors` check `rule:read` on the id plus
read on the telemetry resources the rule's query touches, so a broad
telemetry grant alone cannot read another rule's firing history.
- `filter_keys` and `filter_values` check `rule:read` on the id plus
list and read on the field resources of the signals the rule queries.
The field kinds come from the same query walker `query_range` uses,
mapped telemetry kind to field kind.
- Integration tests cover each gate with and without the grant. No
migration, rule tuples come from #13251.

#### Issues closed by this PR

Part of SigNoz/pulse-pod#286
Part of SigNoz/pulse-pod#355

#### Additional Information

Fourth of five stacked PRs splitting #12927. Stacked on #13251.
2026-11-05 15:42:36 +00:00
Srikanth Chekuri
add47f6516 fix(telemetrystore): return 400 for five more ClickHouse SQL mistakes (#13444)
- Five ClickHouse errors that user SQL causes returned 500. They now
return 400 with a suggestion, as #13328 does for 170, 288, and 407: 386
(no common type), 215 (column neither in GROUP BY nor in an aggregate),
184 (aggregate where none is allowed, mostly an alias that shadows a
column in WHERE), 190 (arrays of different sizes), 427 (a regular
expression RE2 cannot compile).
- Code 48 stays a 500. A correlated subquery over a distributed table
raises it, but the code covers every unimplemented feature, so a mapping
could hide a fault in generated SQL.
- 215 and 386 can also come from a builder bug; that is the trade-off
#13328 made.
- `test_clickhouse_user_mistake_is_bad_request` raises each code with a
real query, plus a builder `REGEXP` filter.
2026-11-05 15:17:03 +00:00
Gaurav Tewari
2d3c4fd7d2 fix(ai-assistant): disable open actions for the page already open (#13370)
#### Description
- Clicking a Noz "Open …" action (e.g. "Open alert") while already on
that resource re-navigated to the same page; the page re-initialised and
the Noz chat jumped up.
- Open-resource chips are now disabled, with a "You're already viewing
this page" tooltip, when their target is the current page. Elsewhere
they navigate as before.
- The alert Overview tab counts as the alert's page, since it renders
the same editor as `/alerts/edit`; the History tab keeps the button
enabled.
- Saved-view chips are intentionally never disabled: reopening one
reapplies the saved query, resetting unsaved explorer edits.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=201428604&issue=SigNoz%7Cnerve-pod%7C69

#### Screenshots / Screen Recordings

Before - 


https://github.com/user-attachments/assets/56983c3a-cc75-4290-88b6-cdfc893f5984


Now - 


https://github.com/user-attachments/assets/63507ea7-f2e5-44ae-b722-771188cb17e3



#### Additional Information
<img width="648" height="329" alt="image"
src="https://github.com/user-attachments/assets/656855d9-64c5-47ea-ab49-2ea917c247d4"
/>

in Main 3 tests fails , i have added 2 extra test cases for the base
folow. lmk if we should remove them.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 13:17:39 +00:00
Abhi kumar
8f86b59822 feat(dashboard): group small pie slices into an automatic Other slice (#13428)
#### Description

Groups small pie slices into one automatic **Other** slice, so a pie
with many groups stays readable.

- A slice under 1% of the total joins Other, and the pie draws at most
15 slices; Other needs at least 2 members and is always the last arc.
- Automatic, with no panel setting.
- Legend lists every series; hiding or showing one regroups Other from
the series left showing.
- Hovering Other lists its members in the timeseries-style tooltip;
clicking pins it, and clicking a member drills into that series through
the existing slice drilldown.
- Storybook: `Pages/Dashboards/Detail/Panels/PieOther`.



https://github.com/user-attachments/assets/143626a2-7e8a-48ac-a9cd-742f0fcd18ec



#### Issues closed by this PR

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

#### Additional Information

- V2 dashboards only; V1 pie is unchanged.
2026-11-05 11:34:01 +00:00
ashu8912
e53d16157b test(e2e): scroll drivers with the mouse wheel (#13433)
#### Description

- Drivers now scroll with the mouse wheel through a new `wheelTo` helper
(`drivers/scroll.ts`) instead of `scrollIntoView` /
`scrollIntoViewIfNeeded`. Scripted scrolling moves even an `overflow:
hidden` box a user can't scroll, so the channel form clipping passed e2e
while users couldn't reach the fields.
- `wheelTo` wheels over the target's visible scroll container until the
target is centred and not covered by a sticky header, then asserts
`toBeInViewport()`. If a turn doesn't move the target, it fails with "a
container clips it instead of scrolling".
- oxlint now rejects `scrollIntoView` and `scrollIntoViewIfNeeded` in
`tests/e2e`, and `AGENTS.md` adds the rule.
- The panel handle's `scrollIntoView()` is renamed to `wheelIntoView()`.
- The channel form (`fillField`) and the alert rule form (threshold
channels, labels, advanced options, evaluation settings) wheel to lower
controls before acting, so a clipped form fails the existing specs, not
only the dedicated regression spec.

#### Additional Information

- Other `click()` / `fill()` calls still use Playwright's scripted
scroll; the wheel is applied where content can sit below the fold.
- Verified on Chromium only.


Closes: https://github.com/SigNoz/platform-pod/issues/3338
2026-11-05 11:10:34 +00:00
Ashwin Bhatkal
83ebe26877 ci(e2e): run the suite on 10 shards (#13395)
#### Description

- The e2e suite now runs on 10 shards instead of 4. Since
[#13189](https://github.com/SigNoz/signoz/pull/13189) the suite is about
twice as long, so each of the 4 shards spends 16 to 20 min in tests and
a PR waits 23 to 27 min for e2e.
- Each shard also pays about 7 min of fixed setup, so 10 shards should
bring the slowest one to about 16 to 17 min. Past 10, every 2 extra
shards save only about 1.5 min.
2026-11-05 11:04:21 +00:00
Aditya Singh
33d2ff917f feat(explorer): make the traces and logs explorer toolbars and tables match (#13411)
#### Description

- The traces and logs explorers now look the same above and around the
table. Both toolbars use the same row height, spacing and controls.
- The traces Options control is now an icon button, the same as the logs
one.
- The logs Options menu now uses the design system buttons and number
input. Its rows no longer fill on hover. The logs Options button no
longer turns blue on hover.
- Max lines per row now clamps a typed value to 1–10. Before, it ignored
1 and accepted 2.5.
- The logs list now reaches the bottom of the page.
- The logs list and table now use Inter, the same as traces. Only the
raw view keeps the mono font.
- The traces and logs table rows are now 30px tall, the row height in
the design.

#### Issues closed by this PR

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

#### Screenshots

https://claude.ai/artifact/6zsvLZFYYzfFrPy9r6Xq9F?sk=ymS3KLyoZjWMf2axjY1-ng

#### Additional Information

- Stacked on #13392.
- The traces styles moved to CSS modules. The LLM Observability explorer
renders the same global classes, so global rules from one page could
restyle the other.
2026-11-05 10:22:09 +00:00
ashu8912
13440740c4 fix(alerts): keep configuration sidebar item weight constant when active (#13432)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Configuration sidebar items (Notification Channels, Routing Policies,
Planned Downtime) no longer turn bold when selected; only the colour
changes, so the label no longer shifts.

Closes: https://github.com/SigNoz/pulse-pod/issues/693
2026-11-05 06:13:01 +00:00
Aditya Singh
711f0b6beb feat(traces-explorer): add infinite scroll to list view; table ui update (#13392)
#### Description

- List view now loads spans on scroll, 100 at a time. It stops once a
page comes back short or the query limit is hit. Previous / Next
controls are removed from the list.
- Traces tab now uses the table's own pager. Page and page size are kept
in the URL. Default is 20 traces per page instead of 10 so the table
fills the space. API does not send a total, so the pager offers the next
page only after a full page.
- New query clears the old rows and shows the loader. It also resets the
traces tab to page 1 and closes the span drawer.
- Span drawer is now on for everyone. Removed the
`TRACES_SPAN_DRAWER_ENABLED` flag.
- Added a hover icon at the start of each row to open the trace in a new
tab. If the API returns the same span twice, only the hovered row shows
the icon.
- Table ui: list and traces tables run edge to edge, empty cells show a
dash in the border colour, rows use the table default height and the
hover / active colours from design.

#### Issues closed by this PR

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

#### Screen recording 



https://github.com/user-attachments/assets/4f7897bd-0c6b-4ecf-aaf2-231834082547




https://claude.ai/artifact/LmXzEheZYZ1xdpshNjGBRf?sk=dOC3fM6uD0n_fKnzLAnZXQ

#### Additional Information

- If the data ends exactly on a full page, Next opens an empty page and
the pager steps back to the last one.
- A failed page request hides the pager. Same as other explorers, so
keeping it as is for now.
- Icon sits in a left gutter as discussed with design.
2026-11-05 05:23:11 +00:00
ashu8912
98f9850c53 fix(alerts): fall back to default range on invalid relativeTime (#13375)
#### Description

- Alert Details crashed to the error boundary when the URL
`relativeTime` couldn't be parsed (e.g. `?relativeTime=1y`). It now
falls back to the default 30m range.
- The "triggers in …" and stats card labels show the range actually
queried, not the raw param.
- `relativeTime=3months` (accepted by validation since #12402) silently
resolved to 30 minutes. Multi-month values now resolve to 30 days per
month.
- Moved `useRouteTabUtils` into its own file to break the `hooks.tsx` ↔
`container/AlertHistory` import cycle.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#674

#### Additional Information

- Repro: open `/alerts/overview?ruleId=<id>&relativeTime=1y` (crashes on
`main`), or `relativeTime=3months` (shows 30m of history).
- No story change. The fixed page renders the same as the existing
AlertOverview story.
2026-11-05 03:12:55 +00:00
164 changed files with 5255 additions and 1755 deletions

View File

@@ -67,7 +67,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
@@ -162,7 +162,7 @@ jobs:
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
- name: teardown-stack
if: always()
run: |

View File

@@ -28287,6 +28287,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28295,9 +28301,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- tokenizer:
- VIEWER
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
summary: Get rule history filter keys
tags:
- rules
@@ -28465,6 +28477,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28473,9 +28491,15 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- tokenizer:
- VIEWER
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
summary: Get rule history filter values
tags:
- rules
@@ -28548,9 +28572,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule overall status timeline
tags:
- rules
@@ -28620,9 +28644,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- tokenizer:
- VIEWER
- rule:read
summary: Get rule history stats
tags:
- rules
@@ -28704,6 +28728,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28712,9 +28742,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get rule history timeline
tags:
- rules
@@ -28779,6 +28819,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28787,9 +28833,19 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Get top contributors to rule firing
tags:
- rules

View File

@@ -18,71 +18,51 @@
right: -2px;
width: 240px;
[data-format-menu='back'],
[data-format-menu='font-option'],
[data-format-menu='font-size'],
[data-format-menu='edit-columns'] {
--button-justify-content: flex-start;
}
[data-format-menu='font-option'],
[data-format-menu='font-size'],
[data-format-menu='edit-columns'] {
--button-label-min-width: 100%;
--button-font-size: 13px;
--button-variant-link-font-weight: 400;
--button-size-line-height: 20px;
}
[data-format-menu='back'] {
text-transform: uppercase;
}
[data-format-menu='font-option'],
[data-format-menu='font-size'] {
text-transform: capitalize;
}
.menu-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.font-size-dropdown {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
.back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 12px;
border: none !important;
box-shadow: none !important;
.icon {
flex-shrink: 0;
}
.text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: 0.14px;
text-transform: uppercase;
}
}
.back-btn:hover {
background-color: unset !important;
.horizontal-line {
margin-inline: -12px;
}
.content {
display: flex;
flex-direction: column;
.option-btn {
display: flex;
align-items: center;
padding: 12px;
border: none !important;
box-shadow: none !important;
justify-content: space-between;
.icon {
flex-shrink: 0;
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal; /* 142.857% */
letter-spacing: 0.14px;
text-transform: capitalize;
}
.text:hover {
color: var(--l2-foreground);
}
}
.option-btn:hover {
background-color: unset !important;
}
}
}
@@ -102,31 +82,6 @@
letter-spacing: 0.88px;
text-transform: uppercase;
}
.value {
display: flex;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.font-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
text-transform: capitalize;
}
.icon {
}
}
.value:hover {
background-color: unset !important;
}
}
.menu-container {
@@ -198,90 +153,10 @@
cursor: pointer;
}
}
.max-lines-per-row-input {
display: flex;
.ant-input-number-handler-wrap {
display: none;
}
.ant-input-number {
min-width: 36px;
width: auto;
border-right: none;
border-left: none;
border-top: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
text-align: center;
height: 26px;
border-radius: 0;
&:active,
&:focus {
border: none;
box-shadow: none;
}
}
.ant-input-number-focused {
box-shadow: none !important;
}
.ant-input-number-input-wrap {
input {
text-align: center;
font-size: 13px;
&:active,
&:focus {
border: none;
}
}
&:active,
&:focus {
border: none;
}
}
.periscope-btn {
box-shadow: none;
padding: 6px 12px;
height: 26px;
border-radius: 0px 1px 1px 0px;
background: var(--l3-background);
}
}
}
.edit-columns-container {
padding: 12px;
.edit-columns-btn {
display: flex;
width: 100%;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.edit-columns-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
.edit-columns-btn:hover {
background-color: unset !important;
}
}
.add-new-column-container {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import cx from 'classnames';
import {
LogViewMode,
@@ -11,13 +12,15 @@ import {
Check,
ChevronLeft,
ChevronRight,
Minus,
Plus,
SlidersVertical,
} from '@signozhq/icons';
import './LogsFormatOptionsMenu.styles.scss';
const MIN_LINES_PER_ROW = 1;
const MAX_LINES_PER_ROW = 10;
const FONT_SIZES = [FontSize.SMALL, FontSize.MEDIUM, FontSize.LARGE];
interface LogsFormatOptionsMenuProps {
items: any;
selectedOptionFormat: any;
@@ -62,25 +65,11 @@ function OptionsMenu({
onChange(key);
};
const incrementMaxLinesPerRow = (): void => {
if (maxLinesPerRow < 10) {
setMaxLinesPerRow(maxLinesPerRow + 1);
}
};
const decrementMaxLinesPerRow = (): void => {
if (maxLinesPerRow > 1) {
setMaxLinesPerRow(maxLinesPerRow - 1);
}
};
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
if (
maxLinesPerRow &&
Number.isInteger(maxLinesNumber) &&
maxLinesPerRow > 1
) {
setMaxLinesPerRow(maxLinesPerRow);
const handleLinesPerRowChange = (value: number | null): void => {
if (value !== null) {
setMaxLinesPerRow(
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
);
}
};
@@ -111,51 +100,36 @@ function OptionsMenu({
{isFontSizeOptionsOpen ? (
<div className="font-size-dropdown">
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="back"
testId="font-size-back"
prefix={<ChevronLeft size={14} />}
onClick={(): void => setIsFontSizeOptionsOpen(false)}
className="back-btn"
type="text"
>
<ChevronLeft size={14} className="icon" />
<Typography.Text className="text">Select font size</Typography.Text>
Select font size
</Button>
<div className="horizontal-line" />
<div className="content">
<Button
onClick={(): void => {
setFontSizeValue(FontSize.SMALL);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.SMALL}</Typography.Text>
{fontSizeValue === FontSize.SMALL && (
<Check size={14} className="icon" />
)}
</Button>
<Button
onClick={(): void => {
setFontSizeValue(FontSize.MEDIUM);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.MEDIUM}</Typography.Text>
{fontSizeValue === FontSize.MEDIUM && (
<Check size={14} className="icon" />
)}
</Button>
<Button
onClick={(): void => {
setFontSizeValue(FontSize.LARGE);
}}
className="option-btn"
type="text"
>
<Typography.Text className="text">{FontSize.LARGE}</Typography.Text>
{fontSizeValue === FontSize.LARGE && (
<Check size={14} className="icon" />
)}
</Button>
{FONT_SIZES.map((size) => (
<Button
key={size}
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-option"
testId={`font-size-option-${size}`}
onClick={(): void => setFontSizeValue(size)}
>
<span className="menu-row">
{size}
{fontSizeValue === size && <Check size={14} />}
</span>
</Button>
))}
</div>
</div>
) : (
@@ -163,14 +137,20 @@ function OptionsMenu({
<div className="font-size-container">
<div className="title">Font Size</div>
<Button
className="value"
type="text"
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-size"
testId="font-size-select"
onClick={(): void => {
setIsFontSizeOptionsOpen(true);
}}
>
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
<ChevronRight size={14} className="icon" />
<span className="menu-row">
{fontSizeValue}
<ChevronRight size={14} />
</span>
</Button>
</div>
<div className="horizontal-line" />
@@ -200,30 +180,20 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </div>
<div className="raw-format max-lines-per-row-input">
<button
type="button"
className="periscope-btn"
onClick={decrementMaxLinesPerRow}
>
{' '}
<Minus size={12} />{' '}
</button>
<InputNumber
min={1}
max={10}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
/>
<button
type="button"
className="periscope-btn"
onClick={incrementMaxLinesPerRow}
>
{' '}
<Plus size={12} />{' '}
</button>
</div>
<InputNumber
controls
mode="spinner"
size="small"
min={MIN_LINES_PER_ROW}
max={MAX_LINES_PER_ROW}
precision={0}
value={maxLinesPerRow}
onChange={handleLinesPerRowChange}
aria-label="Max lines per row"
decrementAriaLabel="Fewer lines per row"
incrementAriaLabel="More lines per row"
testId="max-lines-per-row"
/>
</div>
</>
)}
@@ -233,15 +203,18 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="edit-columns-container">
<Button
className="edit-columns-btn"
type="text"
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="edit-columns"
onClick={handleEditColumns}
data-testid="periscope-btn-edit-columns"
testId="periscope-btn-edit-columns"
>
<Typography.Text className="edit-columns-text">
<span className="menu-row">
Edit columns
</Typography.Text>
<ChevronRight size={14} className="icon" />
<ChevronRight size={14} />
</span>
</Button>
</div>
</>
@@ -280,10 +253,15 @@ function LogsFormatOptionsMenu({
>
<Tooltip title="Options">
<Button
className="periscope-btn ghost"
icon={<SlidersVertical size="md" />}
data-testid="periscope-btn-format-options"
/>
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Options"
testId="periscope-btn-format-options"
>
<SlidersVertical size={14} />
</Button>
</Tooltip>
</Popover>
);

View File

@@ -1,5 +1,11 @@
import { FontSize } from 'container/OptionsMenu/types';
import { fireEvent, render, waitFor } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
@@ -107,18 +113,12 @@ describe('LogsFormatOptionsMenu (unit)', () => {
});
it('increments max-lines and calls onChange', async () => {
const { maxLinesOnChange } = setup();
const { maxLinesOnChange, getByTestId } = setup();
// Increment max lines
const input = document.querySelector(
'.max-lines-per-row-input input',
) as HTMLInputElement;
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const initial = Number(input.value);
const buttons = document.querySelectorAll(
'.max-lines-per-row-input .periscope-btn',
);
const incrementBtn = buttons[1] as HTMLElement;
fireEvent.click(incrementBtn);
fireEvent.click(screen.getByRole('button', { name: 'More lines per row' }));
await waitFor(() => {
expect(Number(input.value)).toBe(initial + 1);
@@ -128,28 +128,47 @@ describe('LogsFormatOptionsMenu (unit)', () => {
});
});
it('clamps a typed max-lines value to 10', async () => {
const { maxLinesOnChange, getByTestId } = setup();
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '15');
await user.tab();
await waitFor(() => expect(input.value).toBe('10'));
expect(maxLinesOnChange).toHaveBeenLastCalledWith(10);
});
it('stops max-lines at 10', async () => {
const { getByTestId } = setup();
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
const increment = screen.getByRole('button', { name: 'More lines per row' });
const user = userEvent.setup();
for (let i = 0; i < 12; i++) {
await user.click(increment);
}
await waitFor(() => expect(input.value).toBe('10'));
});
it('changes font size to MEDIUM and calls onChange', async () => {
const { fontSizeOnChange } = setup();
const { fontSizeOnChange, getByTestId } = setup();
// Open font dropdown
const fontButton = document.querySelector(
'.font-size-container .value',
) as HTMLElement;
fireEvent.click(fontButton);
fireEvent.click(getByTestId('font-size-select'));
// Choose MEDIUM
const optionButtons = Array.from(
document.querySelectorAll('.font-size-dropdown .option-btn'),
);
const mediumBtn = optionButtons[1] as HTMLElement;
fireEvent.click(mediumBtn);
fireEvent.click(getByTestId(`font-size-option-${FontSize.MEDIUM}`));
await waitFor(() => {
expect(
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
).toHaveLength(1);
expect(
(optionButtons[1] as Element).querySelector('.icon'),
getByTestId(`font-size-option-${FontSize.MEDIUM}`).querySelector('svg'),
).toBeInTheDocument();
expect(
getByTestId(`font-size-option-${FontSize.SMALL}`).querySelector('svg'),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
@@ -46,5 +47,4 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
}

View File

@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
resolveSavedViewSourceHint,
} from './utils/resolveOpenResource';
import { ResourceType, resourceRoute } from './utils/resourceRoute';
import { ResourceType } from './utils/resourceRoute';
import styles from './ActionsSection.module.scss';
@@ -397,7 +398,7 @@ export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const { pathname } = useAppLocation();
const { pathname, search } = useAppLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
@@ -493,23 +494,17 @@ export default function ActionsSection({
return;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
const target = getOpenResourceTarget(action);
if (!target) {
return;
}
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(resourceType),
resourceId,
targetModule: targetModuleForResource(target.resourceType),
resourceId: target.resourceId,
});
navigate(path);
navigate(target.path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
@@ -607,6 +602,10 @@ export default function ActionsSection({
// is still surfaced visually via the suffix pill below.
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
const openTarget = getOpenResourceTarget(action);
const isViewingResource =
openTarget !== null &&
isOnResourcePage(openTarget.path, { pathname, search });
let icon: JSX.Element;
if (isSuccess) {
@@ -619,12 +618,16 @@ export default function ActionsSection({
const chip = (
<Button
disabledTooltip="This action already ran"
disabledTooltip={
isViewingResource
? "You're already viewing this page"
: 'This action already ran'
}
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => handleClick(key, action)}
disabled={isSuccess}
disabled={isSuccess || isViewingResource}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}

View File

@@ -0,0 +1,99 @@
import {
MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import logEvent from 'api/common/logEvent';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import { AIAssistantEvents } from '../../../events';
import ActionsSection from '../ActionsSection';
jest.mock('api/common/logEvent');
const mockLogEvent = jest.mocked(logEvent);
const OPEN_ALERT: MessageActionDTO = {
kind: MessageActionKindDTO.open_resource,
label: 'Open alert',
resourceType: 'alert',
resourceId: 'rule-1',
actionMetadataId: 'meta-1',
};
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
describe('ActionsSection open_resource', () => {
beforeEach(() => {
mockLogEvent.mockClear();
});
it.each([
'/alerts/edit?ruleId=rule-1&tab=1',
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
])('disables Open alert while that alert is open at %s', async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
expect(chip).toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
route,
);
expect(mockLogEvent).not.toHaveBeenCalled();
});
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
'opens the alert from %s',
async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
await waitFor(() =>
expect(
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
).toBe('/alerts/edit?ruleId=rule-1'),
);
expect(mockLogEvent).toHaveBeenCalledWith(
AIAssistantEvents.ResourceOpened,
expect.objectContaining({ resourceId: 'rule-1' }),
);
},
);
it('enables Open alert again after navigating away from that alert', () => {
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
undefined,
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
);
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
'aria-disabled',
'true',
);
act(() => navigate('/alerts/edit?ruleId=rule-2'));
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
});

View File

@@ -0,0 +1,39 @@
import {
type MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
} from './resolveOpenResource';
import { resourceRoute } from './resourceRoute';
export interface OpenResourceTarget {
path: string;
resourceType: string;
resourceId: string;
}
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
export function getOpenResourceTarget(
action: MessageActionDTO,
): OpenResourceTarget | null {
if (
action.kind !== MessageActionKindDTO.open_resource ||
isSavedViewOpenAction(action)
) {
return null;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return null;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
return null;
}
return { path, resourceType, resourceId };
}

View File

@@ -0,0 +1,23 @@
import ROUTES from 'constants/routes';
import type { AppLocation } from 'lib/router/types';
// The alert details Overview tab renders the same editor as the edit route.
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
};
// Extra params on the current page (time range, view options) don't make it a different resource.
export function isOnResourcePage(
path: string,
location: Pick<AppLocation, 'pathname' | 'search'>,
): boolean {
const [pathname, query = ''] = path.split('?');
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
if (!pathnames.includes(location.pathname)) {
return false;
}
const current = new URLSearchParams(location.search);
return [...new URLSearchParams(query)].every(
([key, value]) => current.get(key) === value,
);
}

View File

@@ -3,6 +3,7 @@ import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
@@ -86,7 +87,7 @@ function StatsCard({
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { relativeTime } = useAlertHistoryQueryParams();
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import {
useAlertHistoryQueryParams,
useGetAlertRuleDetailsTimelineGraphData,
} from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
@@ -13,9 +15,7 @@ function GraphWrapper({
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { relativeTime } = useAlertHistoryQueryParams();
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineGraphData();

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import { useAppSearchParams } from 'lib/router/useAppSearchParams';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
@@ -85,8 +86,9 @@ function Explorer(): JSX.Element {
setIsLoadingQueries(false);
}, [queryClient]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
const selectedView = useMemo(
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
[searchParams, panelTypesFromUrl],
);
const [warning, setWarning] = useState<Warning | undefined>();
@@ -115,8 +117,6 @@ function Explorer(): JSX.Element {
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
@@ -190,6 +190,7 @@ function Explorer(): JSX.Element {
testId="llm-observability-explorer"
showFilters={isOpen}
onToggleFilters={(): void => setOpen(!isOpen)}
savedViewProps={{ source: SavedviewtypesSourceDTO.ai_observability }}
quickFilterProps={{
className: 'qf-traces-explorer',
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useAppNavigate } from 'lib/router/useAppNavigate';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import type { ColumnSizingState } from '@tanstack/react-table';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
@@ -31,6 +32,8 @@ export type TracesTableProps = {
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
cellTypographySize?: CellTypographySize;
};
@@ -48,6 +51,8 @@ function TracesTable({
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
columnSizing,
onColumnSizingChange,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const navigate = useAppNavigate();
@@ -98,6 +103,8 @@ function TracesTable({
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
columnSizing={columnSizing}
onColumnSizingChange={onColumnSizingChange}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
@@ -113,6 +120,8 @@ TracesTable.defaultProps = {
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
columnSizing: undefined,
onColumnSizingChange: undefined,
cellTypographySize: 'medium',
};

View File

@@ -11,13 +11,16 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
// An aggregate that starts hidden, so the persisted defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'unlisted_aggregate' },
]);
]).map((column) =>
column.id === 'unlisted_aggregate'
? { ...column, defaultVisibility: false }
: column,
);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);

View File

@@ -12,11 +12,19 @@ import { QueryKey } from 'react-query';
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { ColumnSizingState } from '@tanstack/react-table';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
setColumnSizing,
useColumnSizing,
} from 'components/TanStackTableView/useColumnStore';
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/useOptionsMenu';
import { getTraceLink } from '../ListView/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
@@ -40,8 +48,11 @@ import {
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
} from '../constants';
import { getListViewQuery } from '../explorerUtils';
import { PER_PAGE_OPTIONS } from './configs';
import { useTraceViewColumns } from './useTraceViewColumns';
import {
buildTraceViewColumns,
PER_PAGE_OPTIONS,
REQUIRED_FIELDS,
} from './configs';
import styles from './TracesView.module.scss';
interface TracesViewProps {
@@ -65,14 +76,37 @@ function TracesView({
[stagedQuery],
);
const {
columns,
selectedFields,
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const { options, config } = useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.ai_observability,
});
const tableColumns = useMemo(
() => buildTraceViewColumns(options.selectColumns),
[options.selectColumns],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(cols.map((column) => column.id));
},
[config],
);
// The column store keeps widths only; the picked columns live in the slice.
const columnSizing = useColumnSizing(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
);
const handleColumnSizingChange = useCallback<
Dispatch<SetStateAction<ColumnSizingState>>
>(
(updater) => {
setColumnSizing(
LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS,
typeof updater === 'function' ? updater(columnSizing) : updater,
);
},
[columnSizing],
);
const {
selectedTime: globalSelectedTime,
@@ -174,24 +208,6 @@ function TracesView({
[currentQuery, redirectWithQueryBuilderData],
);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() =>
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 (
<div className={styles.container}>
<div className={styles.actionsContainer}>
@@ -215,11 +231,11 @@ function TracesView({
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
config={fieldsSelectorConfig}
config={config}
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
requiredFields={requiredFields}
requiredFields={REQUIRED_FIELDS}
/>
</div>
</div>
@@ -227,15 +243,13 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
columnSizing={columnSizing}
onColumnSizingChange={handleColumnSizingChange}
panelType="TRACE"
getRowHref={getTraceLink}
isLoading={isLoading || isColumnsLoading}
isLoading={isLoading}
isFetching={isFetching}
isError={isError}
error={error}

View File

@@ -0,0 +1,182 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { TRACE_VIEW_DEFAULT_COLUMNS } from '../../constants';
import type { TracesTableProps } from '../../TracesTable/TracesTable';
import TracesView from '../TracesView';
jest.mock('../../TracesTable/TracesTable', () => ({
__esModule: true,
default: ({
columns,
onColumnOrderChange,
onColumnRemove,
onColumnSizingChange,
}: TracesTableProps): JSX.Element => (
<div>
<div data-testid="table-columns">
{columns.map((column) => String(column.header)).join(',')}
</div>
<button
data-testid="reverse-columns"
type="button"
onClick={(): void => onColumnOrderChange?.([...columns].reverse())}
>
reverse
</button>
<button
data-testid="remove-total-tokens"
type="button"
onClick={(): void => onColumnRemove?.('trace:total_tokens:float64')}
>
remove
</button>
<button
data-testid="resize-trace-id"
type="button"
onClick={(): void => onColumnSizingChange?.({ trace_id: 240 })}
>
resize
</button>
</div>
),
}));
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
const SIZING_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const TOTAL_TOKENS = {
name: 'total_tokens',
fieldContext: 'trace',
fieldDataType: 'float64',
};
const storedColumnNames = (): string[] | undefined => {
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
return raw
? (JSON.parse(raw)[PANEL_TYPES.TRACE] as { name: string }[]).map(
(field) => field.name,
)
: undefined;
};
const shownColumns = (): string[] =>
(screen.getByTestId('table-columns').textContent ?? '').split(',');
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 columns', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { type: 'trace', data: {} } })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('shows the default columns while nothing is picked, storing nothing', () => {
renderTracesView();
expect(shownColumns()).toStrictEqual(
TRACE_VIEW_DEFAULT_COLUMNS.map((field) => field.name),
);
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
});
it('shows trace_id first when a stored list lacks it', () => {
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
);
renderTracesView();
expect(shownColumns()).toStrictEqual(['trace_id', 'total_tokens']);
});
it('stores the order a column drag leaves behind', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
}),
);
renderTracesView();
await user.click(screen.getByTestId('reverse-columns'));
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
});
it('keeps a dragged trace_id in place when the stored list lacks it', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({ [PANEL_TYPES.TRACE]: [TOTAL_TOKENS] }),
);
renderTracesView();
await user.click(screen.getByTestId('reverse-columns'));
expect(storedColumnNames()).toStrictEqual(['total_tokens', 'trace_id']);
expect(shownColumns()).toStrictEqual(['total_tokens', 'trace_id']);
});
it('drops a column removed from the table', async () => {
const user = userEvent.setup();
localStorage.setItem(
PANEL_COLUMNS_KEY,
JSON.stringify({
[PANEL_TYPES.TRACE]: [{ name: 'trace_id' }, TOTAL_TOKENS],
}),
);
renderTracesView();
await user.click(screen.getByTestId('remove-total-tokens'));
expect(storedColumnNames()).toStrictEqual(['trace_id']);
expect(shownColumns()).toStrictEqual(['trace_id']);
});
it('keeps column widths in the column store', async () => {
const user = userEvent.setup();
renderTracesView();
await user.click(screen.getByTestId('resize-trace-id'));
expect(
useColumnStore.getState().tables[SIZING_KEY].columnSizing,
).toStrictEqual({ trace_id: 240 });
expect(localStorage.getItem(PANEL_COLUMNS_KEY)).toBeNull();
});
});

View File

@@ -12,8 +12,7 @@ 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 PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
@@ -84,9 +83,9 @@ const mockFieldKeysFailure = (): void => {
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
const storedOptions = (): Record<string, { name: string }[]> | null => {
const raw = localStorage.getItem(PANEL_COLUMNS_KEY);
return raw ? (JSON.parse(raw) as Record<string, { name: string }[]>) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
@@ -121,16 +120,16 @@ describe('TracesView column persistence', () => {
// 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 () => {
it('shows the default columns once the field keys arrive, storing nothing', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(storedOptions()).toBeNull();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
@@ -140,20 +139,19 @@ describe('TracesView column persistence', () => {
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
expect(storedOptions()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
it('keeps the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
});
it('renders the display-only columns when the field keys fail', async () => {
it('renders the default columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -162,22 +160,27 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
it('shows a stored selection, in its order, while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
[PANEL_TYPES.TRACE]: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'trace_id' },
],
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
localStorage.setItem(PANEL_COLUMNS_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
const headers = screen
.getAllByRole('columnheader')
.map((header) => header.textContent);
expect(headers).toStrictEqual(['service.name', 'trace_id']);
expect(storedOptions()).toStrictEqual(existing);
});
});

View File

@@ -1,302 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useTraceViewColumns } from '../useTraceViewColumns';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const AGGREGATE_KEYS = [
'llm_call_count',
'tool_call_count',
'distinct_tool_count',
'input_tokens',
'output_tokens',
'total_tokens',
'estimated_total_cost',
'max_llm_duration_nano',
];
const fieldNames = (fields: TelemetryFieldKey[]): string[] =>
fields.map((field) => field.name);
const columnNames = (columns: { header?: unknown }[]): string[] =>
columns.map((column) => column.header as string);
function wrapper({ children }: { children: ReactNode }): JSX.Element {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const seenAI: URLSearchParams[] = [];
const mockAggregateKeys = (names: string[]): void => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(req, res, ctx) => {
seenAI.push(req.url.searchParams);
return 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 renderColumns = async (): Promise<
ReturnType<typeof renderHook<ReturnType<typeof useTraceViewColumns>, unknown>>
> => {
const rendered = renderHook(() => useTraceViewColumns(), { wrapper });
await waitFor(() => {
expect(rendered.result.current.isLoading).toBe(false);
});
return rendered;
};
describe('useTraceViewColumns', () => {
beforeEach(() => {
seenAI.length = 0;
useColumnStore.getState().tables = {};
localStorage.clear();
mockAggregateKeys(AGGREGATE_KEYS);
});
it('reads the aggregates from the trace context of the keys endpoint', async () => {
await renderColumns();
expect(seenAI).toHaveLength(1);
expect(seenAI[0]?.get('searchText')).toBe('');
expect(seenAI[0]?.get('fieldContext')).toBe(
TelemetrytypesFieldContextDTO.trace,
);
});
it('pools the hardcoded display-only columns with the endpoint aggregates', async () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
it('selects only the default-visible columns on first render', async () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
it('keeps a newly reported aggregate hidden until it is picked', async () => {
mockAggregateKeys(['brand_new_aggregate']);
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toContain('brand_new_aggregate');
expect(fieldNames(result.current.selectedFields)).not.toContain(
'brand_new_aggregate',
);
});
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([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'trace_id' },
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'total_tokens',
]);
});
it('shows a column added back from the pool', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }, { name: 'input' }]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'input',
]);
});
it('keeps the trace id column even when the selection drops it', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'span_count' }]);
});
expect(fieldNames(result.current.selectedFields)).toContain('trace_id');
expect(result.current.requiredFields).toStrictEqual(['trace_id']);
});
it('persists the selection order', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([
{ name: 'total_tokens', fieldContext: 'trace', fieldDataType: 'float64' },
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
]);
});
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'total_tokens',
'trace_id',
'service.name',
]);
expect(
useColumnStore.getState().tables[STORAGE_KEY].columnOrder,
).toStrictEqual([
'trace:total_tokens:float64',
'trace_id',
'resource:service.name',
]);
});
});

View File

@@ -3,52 +3,17 @@ import type { TableColumnDef } from 'components/TanStackTableView/types';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
import { TRACE_ID_COLUMN_ID } from '../constants';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
export const REQUIRED_FIELDS: readonly string[] = [TRACE_ID_COLUMN_ID];
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
): TableColumnDef<TracesTableRow>[] =>
fields.map((field) => ({
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,

View File

@@ -1,127 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import { mergeExtraFields } from 'utils/extraFields';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
hideColumn,
initializeFromDefaults,
setColumnOrder,
showColumn,
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TracesTableRow } from '../TracesTable/getFieldColumn';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL, DataSource } from 'types/common/queryBuilder';
import {
TRACE_VIEW_BUILDER_QUERY_TYPE,
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import {
buildTraceViewColumns,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
/** Matches the id getFieldColumn derives, so fields and columns address alike. */
const columnIdOf = (field: TelemetryFieldKey): string =>
buildCompositeKey(field.name, field.fieldContext, field.fieldDataType);
interface UseTraceViewColumns {
columns: TableColumnDef<TracesTableRow>[];
selectedFields: TelemetryFieldKey[];
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,
isSuccess,
} = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
searchText: '',
},
TRACE_VIEW_BUILDER_QUERY_TYPE,
);
const availableFields = useMemo(
() =>
sortByDefaultOrder(
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
),
[fetchedFields],
);
const columns = useMemo(
() => buildTraceViewColumns(availableFields),
[availableFields],
);
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
const selectedFields = useMemo(() => {
const hidden = new Set(hiddenColumnIds);
const orderIndex = new Map(columnOrder.map((id, index) => [id, index]));
return availableFields
.filter((field) => !hidden.has(columnIdOf(field)))
.sort(
(a, b) =>
(orderIndex.get(columnIdOf(a)) ?? Infinity) -
(orderIndex.get(columnIdOf(b)) ?? Infinity),
);
}, [availableFields, hiddenColumnIds, columnOrder]);
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
if (keptIds.has(column.id) || column.id === TRACE_ID_COLUMN_ID) {
showColumn(STORAGE_KEY, column.id);
} else {
hideColumn(STORAGE_KEY, column.id);
}
});
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns, isSuccess],
);
return {
columns,
selectedFields,
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -0,0 +1,46 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { renderWithExplorerProviders } from 'container/SavedViews/__tests__/savedViewsTestUtils';
import { act, screen } from 'tests/test-utils';
import Explorer from '../Explorer';
jest.mock('../ListView/ListView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="ai-list-view" />,
}));
jest.mock('../TableView/TableView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="ai-table-view" />,
}));
const explorerUrlWithPanel = (panelType: PANEL_TYPES): string =>
`${ROUTES.AI_OBSERVABILITY_EXPLORER}?${QueryParams.panelTypes}=${encodeURIComponent(
JSON.stringify(panelType),
)}`;
describe('AI observability Explorer', () => {
it('shows the view of the panel type in the url', () => {
renderWithExplorerProviders(
<Explorer />,
explorerUrlWithPanel(PANEL_TYPES.LIST),
);
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
});
it('follows the url when the panel type changes, as opening a saved view does', () => {
const { history } = renderWithExplorerProviders(
<Explorer />,
explorerUrlWithPanel(PANEL_TYPES.LIST),
);
act(() => {
history.push(explorerUrlWithPanel(PANEL_TYPES.TABLE));
});
expect(screen.getByTestId('ai-table-view')).toBeInTheDocument();
expect(screen.queryByTestId('ai-list-view')).not.toBeInTheDocument();
});
});

View File

@@ -4,6 +4,9 @@ import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
/** Always present: it is the row's link to the trace, but it can be reordered. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
@@ -57,6 +60,81 @@ export const TRACE_VIEW_COLUMN_EXTRA_FIELDS: TelemetryFieldKey[] = [
{ name: 'output' },
] as TelemetryFieldKey[];
/**
* Shown, in this order, until the user picks columns. The aggregates are the
* keys the keys endpoint returns for builder_ai_query; the rest match
* TRACE_VIEW_COLUMN_EXTRA_FIELDS, so both build the same column ids.
*/
export const TRACE_VIEW_DEFAULT_COLUMNS: TelemetryFieldKey[] = [
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'root_span_name' },
{
name: 'estimated_total_cost',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{ name: 'trace_duration_nano' },
{ name: 'span_count' },
{
name: 'total_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'input_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'output_tokens',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'distinct_tool_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'llm_call_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{
name: 'tool_call_count',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
{ name: 'start_time' },
{ name: 'end_time' },
{ name: 'error_count' },
{ name: 'input' },
{ name: 'output' },
{
name: 'max_llm_duration_nano',
signal: 'traces',
fieldContext: 'trace',
fieldDataType: 'float64',
},
];
export const ensureAiRequiredColumns = (
columns: TelemetryFieldKey[],
panelType: PANEL_TYPES,
): TelemetryFieldKey[] =>
panelType !== PANEL_TYPES.TRACE ||
columns.some((column) => column.name === TRACE_ID_COLUMN_ID)
? columns
: [{ name: TRACE_ID_COLUMN_ID }, ...columns];
export const TRACE_VIEW_FIELD_KEYS = {
fieldContext: TelemetrytypesFieldContextDTO.trace,
} as const;

View File

@@ -0,0 +1,3 @@
.logsTable {
--tanstack-table-row-height: 30px;
}

View File

@@ -2,7 +2,6 @@
flex: 1;
display: flex;
flex-direction: column;
font-family: 'Space Mono', monospace;
font-size: 14px;
font-weight: 400;
line-height: 18px;
@@ -19,7 +18,6 @@
thead {
tr {
th {
font-family: 'Space Mono', monospace;
font-size: 12px;
color: white !important;
@@ -37,7 +35,6 @@
}
td {
font-family: 'Space Mono', monospace;
font-size: 12px;
}
@@ -57,7 +54,6 @@
tbody {
tr {
td {
font-family: 'Space Mono', monospace;
font-size: 12px;
padding: 4px !important;

View File

@@ -48,6 +48,7 @@ import {
isTraceToLogsQuery,
} from './utils';
import styles from './LogsExplorerList.module.scss';
import './LogsExplorerList.style.scss';
function Footer(): JSX.Element {
@@ -202,6 +203,7 @@ function LogsExplorerList({
return (
<TanStackTable<ILog>
ref={ref as React.Ref<TanStackTableHandle>}
className={styles.logsTable}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
respectColumnOrder={false}

View File

@@ -1,5 +1,4 @@
.logs-explorer-views-container {
margin-bottom: 24px;
flex: 1;
min-height: 0;
display: flex;

View File

@@ -87,6 +87,7 @@ describe('useOptionsMenu', () => {
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
} satisfies PreferenceContextValue);
mockUseUrlQueryData.mockReturnValue({
@@ -236,6 +237,7 @@ describe('useOptionsMenu', () => {
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
} satisfies PreferenceContextValue);
});
@@ -357,6 +359,7 @@ describe('useOptionsMenu', () => {
},
),
[SavedviewtypesSourceDTO.logs]: slice([], {}),
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
} satisfies PreferenceContextValue);
const { result } = renderHook(

View File

@@ -21,6 +21,7 @@ export const SOURCE_PAGE_SIGNAL: Record<
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
[SavedviewtypesSourceDTO.ai_observability]: DataSource.TRACES,
};
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {

View File

@@ -8,7 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import {
hasPreferences,
isPreferenceSourcePage,
} from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { UseActiveSavedViewResult } from '../types';
@@ -20,9 +23,12 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const hasOptions = isPreferenceSourcePage(source);
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
const hasOptions = hasPreferences(source, viewPanelType);
const { options } = useOptionsMenu({
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
sourcePage: isPreferenceSourcePage(source)
? source
: SavedviewtypesSourceDTO.logs,
});
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
@@ -61,16 +67,18 @@ export function useActiveSavedView(
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
panelType: viewPanelType,
options: hasOptions ? options : undefined,
sourcePage: source,
});
}, [
view,
isError,
isStagedQueryCurrent,
stagedQuery,
panelType,
viewPanelType,
hasOptions,
source,
options,
]);

View File

@@ -21,7 +21,10 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppLocation } from 'lib/router/useAppLocation';
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import {
hasPreferences,
isPreferenceSourcePage,
} from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
@@ -59,9 +62,12 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const hasOptions = isPreferenceSourcePage(source);
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
const hasOptions = hasPreferences(source, viewPanelType);
const { options } = useOptionsMenu({
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
sourcePage: isPreferenceSourcePage(source)
? source
: SavedviewtypesSourceDTO.logs,
});
const preferenceSlice = usePreferenceSlice(source);
@@ -79,12 +85,12 @@ export function useSavedViewActions(
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
toSavedViewSpec({
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
panelType: viewPanelType,
displayName,
options: hasOptions ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, hasOptions, options],
[stagedQuery, currentQuery, viewPanelType, hasOptions, options],
);
const selectView = useCallback(
@@ -101,11 +107,11 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (preferenceSlice) {
const { columns, formatting } = getViewColumnsAndFormatting(
const { columns, formatting, panelType } = getViewColumnsAndFormatting(
view.spec,
source,
);
preferenceSlice.updateColumns(columns);
preferenceSlice.updateColumns(columns, panelType);
if (formatting) {
preferenceSlice.updateFormatting(formatting);
}

View File

@@ -32,6 +32,7 @@ export interface HasUnsavedViewChangesArgs {
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
sourcePage: SavedviewtypesSourceDTO;
}
export interface UseLastUsedViewResult {

View File

@@ -1,6 +1,7 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
@@ -120,6 +121,7 @@ describe('hasUnsavedViewChanges', () => {
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
@@ -139,6 +141,7 @@ describe('hasUnsavedViewChanges', () => {
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
@@ -163,6 +166,7 @@ describe('hasUnsavedViewChanges', () => {
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
@@ -174,6 +178,7 @@ describe('hasUnsavedViewChanges', () => {
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
@@ -193,6 +198,7 @@ describe('hasUnsavedViewChanges', () => {
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
@@ -236,6 +242,7 @@ describe('hasUnsavedViewChanges', () => {
view: olderView,
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
panelType: PANEL_TYPES.TIME_SERIES,
sourcePage: SavedviewtypesSourceDTO.metrics,
}),
).toBe(false);
});
@@ -283,6 +290,7 @@ describe('hasUnsavedViewChanges', () => {
view: storedView,
stagedQuery: stagedWithTag,
panelType: PANEL_TYPES.TIME_SERIES,
sourcePage: SavedviewtypesSourceDTO.metrics,
}),
).toBe(false);
});
@@ -322,6 +330,7 @@ describe('hasUnsavedViewChanges', () => {
view: noOrder,
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(false);
});
@@ -337,6 +346,7 @@ describe('hasUnsavedViewChanges', () => {
view: noOrder,
stagedQuery: sorted,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(true);
@@ -346,6 +356,7 @@ describe('hasUnsavedViewChanges', () => {
view: saved,
stagedQuery: stagedFrom(saved, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(false);
});
@@ -362,6 +373,7 @@ describe('hasUnsavedViewChanges', () => {
view: legacy,
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
}),
).toBe(false);
});
@@ -373,6 +385,7 @@ describe('hasUnsavedViewChanges', () => {
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);

View File

@@ -3,12 +3,18 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import {
DEFAULT_PANEL_TYPE,
ensureAiRequiredColumns,
} from 'container/LLMObservability/Explorer/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import getAiObservabilityLoaderConfig from 'providers/preferences/configs/aiObservabilityLoaderConfig';
import { Preferences } from 'providers/preferences/types';
function withColumnNames(
@@ -47,5 +53,18 @@ export function getViewColumnsAndFormatting(
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
if (sourcePage === SavedviewtypesSourceDTO.ai_observability) {
const panelType =
(spec?.panelType as unknown as PANEL_TYPES | undefined) ??
DEFAULT_PANEL_TYPE;
return {
columns: ensureAiRequiredColumns(
selectedFields ||
getAiObservabilityLoaderConfig(panelType).default().columns,
panelType,
),
panelType,
};
}
return { columns: [] };
}

View File

@@ -1,13 +1,14 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSourceDTO,
TelemetrytypesFieldContextDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
import { toSavedViewSource } from './toSavedViewSource';
// Legacy context names the server stores under their v5 name.
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
@@ -51,6 +52,7 @@ export function hasUnsavedViewChanges({
stagedQuery,
panelType,
options,
sourcePage,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
@@ -77,10 +79,9 @@ export function hasUnsavedViewChanges({
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
toSavedViewSource(dataSource),
sourcePage,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
@@ -88,7 +89,7 @@ export function hasUnsavedViewChanges({
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
if (sourcePage !== SavedviewtypesSourceDTO.logs || !formatting) {
return false;
}
return (

View File

@@ -3,8 +3,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// The views API files each view under a source page, but callers only know the
// signal they query: a builder query's dataSource, or the AI assistant's hint.
// The AI assistant names a view's source by DataSource (the signal), the api
// keys views by source page.
// TODO: remove in a follow-up once callers pass the source page directly.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{

View File

@@ -1,14 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: 8px 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}

View File

@@ -1,82 +1,57 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import { SlidersVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
if (!config?.fieldsSelector) {
return null;
}
const label = t('options_menu.options');
return (
<div className={styles.container}>
{config?.fieldsSelector && (
<>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
)}
<Controls
isLoading={isLoading}
totalCount={totalCount}
offset={pagination.offset}
countPerPage={pagination.limit}
perPageOptions={perPageOptions}
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
<>
<Tooltip title={label}>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={label}
testId="periscope-btn-options-traces"
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
<SlidersVertical size={14} />
</Button>
</Tooltip>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</div>
</>
);
}
type TraceExplorerControlsProps = {
config?: OptionsMenuConfig | null;
};
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

@@ -7,6 +7,32 @@
min-height: 400px;
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
min-height: 40px;
padding: 4px 8px;
border-bottom: 1px solid var(--l1-border);
}
.orderBy {
display: flex;
align-items: center;
gap: 8px;
}
.orderByLabel {
display: flex;
align-items: center;
gap: 4px;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.drawerScope {
display: contents;
}

View File

@@ -1,34 +0,0 @@
.trace-explorer-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
.order-by-container {
display: flex;
align-items: center;
gap: 8px;
.order-by-label {
color: var(--muted-foreground);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
display: flex;
align-items: center;
gap: 4px;
}
.order-by-select {
width: 100px;
.ant-select-selector {
border: none;
box-shadow: none;
background-color: transparent;
}
}
}
}

View File

@@ -0,0 +1,149 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
listResponse,
listRows,
QUERY_RANGE_URL,
renderListView,
} from './testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: (selector: (state: unknown) => unknown): unknown => {
const result = actual.useSelector(selector);
if (result && typeof result === 'object' && 'loading' in result) {
return { ...result, loading: false };
}
return result;
},
};
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
options: {
selectColumns: [
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'name', fieldContext: 'span' },
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
}),
}));
type ListRequest = {
offset: number;
limit: number;
start: number;
end: number;
};
// Answers each offset with `rowsPerOffset[offset]` rows and records every request.
const mockListPages = (
rowsPerOffset: Record<number, number>,
firstPageDelayMs = 0,
): ListRequest[] => {
const requests: ListRequest[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = await req.json<QueryRangeRequestV5>();
const spec = body.compositeQuery.queries[0].spec as {
offset?: number;
limit: number;
};
const offset = spec.offset ?? 0;
requests.push({
offset,
limit: spec.limit,
start: body.start,
end: body.end,
});
const length = Math.min(rowsPerOffset[offset] ?? 0, spec.limit);
const rows = Array.from({ length }, (_, i) => ({
...listRows[0],
data: {
...listRows[0].data,
name: `span-${offset + i}`,
span_id: `span-${offset + i}`,
},
}));
return res(
ctx.delay(offset === 0 ? firstPageDelayMs : 0),
ctx.status(200),
ctx.json(listResponse(rows)),
);
}),
);
return requests;
};
// Tall enough that the mocked virtuoso renders every row and reaches the end.
const FULL_HEIGHT = 200 * 54;
describe('Traces ListView - infinite scroll', () => {
afterEach(() => {
server.resetHandlers();
});
it('loads the next page at the end of the list over the same window', async () => {
// Past the one-second resolution of the window, so a recomputed "now" would show.
const requests = mockListPages({ 0: 100, 100: 5 }, 1100);
renderListView(initialQueriesMap.traces, FULL_HEIGHT);
await expect(
screen.findByText('span-104', undefined, { timeout: 3000 }),
).resolves.toBeInTheDocument();
expect(screen.getByText('span-0')).toBeInTheDocument();
expect(requests.map(({ offset }) => offset)).toStrictEqual([0, 100]);
expect(requests[1].start).toBe(requests[0].start);
expect(requests[1].end).toBe(requests[0].end);
});
it('stops after a short page', async () => {
const requests = mockListPages({ 0: 5 });
renderListView(initialQueriesMap.traces, FULL_HEIGHT);
await expect(screen.findByText('span-4')).resolves.toBeInTheDocument();
expect(requests.map(({ offset }) => offset)).toStrictEqual([0]);
});
it('stops at the query limit', async () => {
const requests = mockListPages({ 0: 100, 100: 100 });
const { builder } = initialQueriesMap.traces;
renderListView(
{
...initialQueriesMap.traces,
builder: {
...builder,
queryData: [{ ...builder.queryData[0], limit: 150 }],
},
} as Query,
FULL_HEIGHT,
);
await expect(screen.findByText('span-149')).resolves.toBeInTheDocument();
expect(
requests.map(({ offset, limit }) => ({ offset, limit })),
).toStrictEqual([
{ offset: 0, limit: 100 },
{ offset: 100, limit: 50 },
]);
});
});

View File

@@ -1,9 +1,18 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { logEventMock } from '__tests__/logEventMock';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
OpenTraceSource,
TracesExplorerEvents,
} from 'container/TracesExplorer/events';
import { getCurrentLocation } from 'lib/router/navigation';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
import {
listRows,
mockSuccess,
mockTraceSummary,
renderListView,
} from './testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
@@ -41,16 +50,21 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
}),
}));
const ROW_TEST_ID = `traces-table-row-${listRows[0].data.span_id}`;
// The row click handler sits on each cell, so a click on the <tr> does nothing.
const getRowCell = (row: HTMLElement): HTMLElement =>
within(row).getAllByRole('cell')[0];
describe('Traces ListView - span drawer navigation', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
jest.restoreAllMocks();
});
it('opens the clicked span and steps through rows with the arrow keys', async () => {
@@ -110,4 +124,98 @@ describe('Traces ListView - span drawer navigation', () => {
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
it('opens the trace page in a new tab from the row icon without opening the drawer', async () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
// user-event leaves the row on its way to the button, which clears the hover.
const user = userEvent.setup({ skipHover: true });
renderListView();
const row = (await screen.findByText('HTTP GET')).closest(
'tr',
) as HTMLElement;
expect(screen.queryByTestId('span-row-open-trace')).not.toBeInTheDocument();
await user.hover(row);
await user.click(await screen.findByTestId('span-row-open-trace'));
expect(open).toHaveBeenCalledWith(
expect.stringMatching(
/\/trace\/0000000000000000344ded1387b08a7e\?.*772c4d29dd9076ac/,
),
'_blank',
);
expect(getCurrentLocation().pathname).toBe('/traces-explorer');
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
expect(logEventMock).toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{
panelType: PANEL_TYPES.LIST,
source: OpenTraceSource.RowIcon,
},
);
});
it('opens the trace page in a new tab from the drawer', async () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
const user = userEvent.setup();
renderListView();
await user.click(getRowCell(await screen.findByTestId(ROW_TEST_ID)));
expect(logEventMock).not.toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{ panelType: PANEL_TYPES.LIST, source: OpenTraceSource.Row },
);
await user.click(await screen.findByTestId('span-drawer-view-full-trace'));
expect(open).toHaveBeenCalledWith(
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
'_blank',
);
expect(logEventMock).toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{
panelType: PANEL_TYPES.LIST,
source: OpenTraceSource.SpanDrawer,
},
);
});
it('opens the trace page in a new tab on a Cmd-click without opening the drawer', async () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
const user = userEvent.setup();
renderListView();
const cell = getRowCell(await screen.findByTestId(ROW_TEST_ID));
await user.keyboard('{Meta>}');
await user.click(cell);
await user.keyboard('{/Meta}');
expect(open).toHaveBeenCalledWith(
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
'_blank',
'noopener',
);
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
expect(logEventMock).toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{
panelType: PANEL_TYPES.LIST,
source: OpenTraceSource.Row,
},
);
});
it('shows the row icon only on the hovered copy of a duplicate span', async () => {
mockSuccess([listRows[0], listRows[0]]);
// user-event leaves the row on its way to the button, which clears the hover.
const user = userEvent.setup({ skipHover: true });
renderListView();
const [first] = await screen.findAllByText('HTTP GET');
await user.hover(first.closest('tr') as HTMLElement);
await expect(
screen.findAllByTestId('span-row-open-trace'),
).resolves.toHaveLength(1);
});
});

View File

@@ -1,6 +1,3 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
@@ -54,13 +51,11 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
describe('Traces ListView - span drawer trace bounds', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});

View File

@@ -88,9 +88,10 @@ export const mockSuccess = (rows: unknown[] = listRows): void => {
export const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
viewportHeight = 500,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight: 54 }}>
<ListView
isFilterApplied={false}
setWarning={jest.fn()}

View File

@@ -78,8 +78,8 @@ describe('Traces ListView - Data Loaded', () => {
'200',
);
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
// empty status fields render the dash icon
expect(screen.getAllByTestId('empty-value').length).toBeGreaterThanOrEqual(1);
});
it('requests every span field instead of the selected columns', async () => {

View File

@@ -1,5 +1,5 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ListView from './index';
@@ -59,16 +59,8 @@ const verifyControlsVisibility = (): void => {
// Order by controls
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
// Pagination controls
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Options menu (settings button) - check for translation key or actual text
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
expect(screen.getByTestId('periscope-btn-options-traces')).toBeInTheDocument();
};
describe('Traces ListView - Error and Empty States', () => {
@@ -153,18 +145,11 @@ describe('Traces ListView - Error and Empty States', () => {
});
// Order by controls should be interactive
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Pagination controls should be present
const previousButton = screen.getByRole('button', { name: /previous/i });
const nextButton = screen.getByRole('button', { name: /next/i });
expect(previousButton).toBeInTheDocument();
expect(nextButton).toBeInTheDocument();
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
});
it('allows interaction with controls in empty state', async () => {
@@ -176,12 +161,11 @@ describe('Traces ListView - Error and Empty States', () => {
});
// All controls should be interactive
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
// Options menu should be clickable
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
});
});
});

View File

@@ -20,22 +20,15 @@ import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import SpanDrawer from 'container/TracesExplorer/SpanDrawer/SpanDrawer';
import { useSpanDrawer } from 'container/TracesExplorer/SpanDrawer/useSpanDrawer';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
@@ -43,14 +36,9 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { getTraceLink, transformSpanRows } from './utils';
import './ListView.styles.scss';
import { defaultSelectedColumns, TIMESTAMP_FIELD } from './configs';
import { useListViewInfiniteQuery } from './useListViewInfiniteQuery';
import { getTraceLink } from './utils';
import styles from './ListView.module.scss';
@@ -96,12 +84,6 @@ function ListView({
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
// Empty selectFields makes the API return every span field.
const requestQuery = useMemo(() => {
const query = getListViewQuery(
@@ -125,51 +107,26 @@ function ListView({
minTime,
stagedQuery,
panelType,
paginationConfig,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
maxTime,
minTime,
],
[stagedQuery, panelType, globalSelectedTime, maxTime, minTime],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
{
const { rows, warning, isFetching, isLoading, isError, error, loadMore } =
useListViewInfiniteQuery({
query: requestQuery,
graphType: panelType,
selectedTime: 'GLOBAL_TIME' as const,
globalSelectedInterval: globalSelectedTime as CustomTimeType,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: paginationConfig,
},
},
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);
});
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
setWarning(warning);
}, [warning, setWarning]);
useEffect(() => {
if (isLoading || isFetching) {
@@ -179,15 +136,8 @@ function ListView({
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const dataLength =
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
const queryTableData = useMemo(
() => queryTableDataResult || [],
[queryTableDataResult],
);
const { tableProps, openTraceColumn, drawerProps, onPressInside } =
useSpanDrawer(rows);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
@@ -196,15 +146,11 @@ function ListView({
(field) => field.name !== TIMESTAMP_FIELD.name,
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
const fieldColumns = fields.map((field) =>
getFieldColumn(field, PANEL_TYPES.LIST),
);
return [openTraceColumn, ...fieldColumns];
}, [options?.selectColumns, openTraceColumn]);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
@@ -230,10 +176,10 @@ function ListView({
return (
<div className={styles.container}>
<div className="trace-explorer-controls">
<div className={styles.controls}>
{headerActions}
<div className="order-by-container">
<div className="order-by-label">
<div className={styles.orderBy}>
<div className={styles.orderByLabel}>
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
@@ -249,12 +195,7 @@ function ListView({
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={totalCount}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
<TraceExplorerControls config={config} />
</div>
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
@@ -273,9 +214,10 @@ function ListView({
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
onEndReached={loadMore}
/>
{drawerProps && <SpanDrawer {...drawerProps} />}
<SpanDrawer {...drawerProps} />
</div>
</div>
);

View File

@@ -0,0 +1,110 @@
import { useCallback, useMemo } from 'react';
import { QueryKey, useInfiniteQuery } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useKeepRowsForSameQuery } from 'container/TracesExplorer/TracesTable/useKeepRowsForSameQuery';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getListLimit, getNextPageOffset, transformSpanRows } from './utils';
type UseListViewInfiniteQueryParams = {
query: Query;
queryKey: QueryKey;
enabled: boolean;
};
type UseListViewInfiniteQueryResult = {
rows: TracesTableRow[];
warning: Warning | undefined;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: Error | null;
loadMore: () => void;
};
export function useListViewInfiniteQuery({
query,
queryKey,
enabled,
}: UseListViewInfiniteQueryParams): UseListViewInfiniteQueryResult {
const limit = getListLimit(query);
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
isPreviousData,
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 0, signal }) =>
GetMetricQueryRange(
{
query,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
// Every page reads the window in redux, so later pages cannot drift.
globalSelectedInterval: 'custom',
params: { dataSource: 'traces' },
tableParams: {
pagination: {
offset: pageParam as number,
limit: limit
? Math.min(DEFAULT_PER_PAGE_VALUE, limit - (pageParam as number))
: DEFAULT_PER_PAGE_VALUE,
},
},
},
ENTITY_VERSION_V5,
undefined,
signal,
),
getNextPageParam: (_lastPage, allPages) =>
getNextPageOffset(
allPages.map(
(page) => page.payload.data.newResult.data.result?.[0]?.list?.length ?? 0,
),
DEFAULT_PER_PAGE_VALUE,
limit,
),
keepPreviousData: true,
enabled,
});
const keepRows = useKeepRowsForSameQuery(isPreviousData);
const rows = useMemo(
() =>
keepRows
? (data?.pages ?? []).flatMap((page) =>
transformSpanRows(page.payload.data.newResult.data.result ?? []),
)
: [],
[data?.pages, keepRows],
);
const loadMore = useCallback((): void => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return {
rows,
warning: data?.pages[data.pages.length - 1]?.warning,
isLoading,
isFetching,
isError,
error: error as Error | null,
loadMore,
};
}

View File

@@ -14,6 +14,7 @@ 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 { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
@@ -178,3 +179,23 @@ export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
};
}) as TracesTableRow[];
};
export const getListLimit = (query: Query): number | null =>
query.builder.queryTraceOperator?.[0]?.limit ??
query.builder.queryData[0]?.limit ??
null;
/** Offset of the next page, or undefined once a short page or the limit ends the list. */
export const getNextPageOffset = (
pageLengths: number[],
pageSize: number,
limit: number | null,
): number | undefined => {
const loaded = pageLengths.reduce((sum, length) => sum + length, 0);
const lastLength = pageLengths[pageLengths.length - 1] ?? 0;
if (lastLength < pageSize || (limit && loaded >= limit)) {
return undefined;
}
return loaded;
};

View File

@@ -0,0 +1,9 @@
// One text line tall, so the taller button overflows into the cell padding
// instead of growing the row on hover.
.slot {
display: flex;
align-items: center;
justify-content: center;
height: var(--tanstack-plain-cell-line-height, 20px);
overflow: visible;
}

View File

@@ -0,0 +1,58 @@
import { MouseEvent } from 'react';
import { CornerUpRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsRowHovered } from 'components/TanStackTableView/TanStackTableStateContext';
import {
logOpenTraceClicked,
OpenTraceSource,
} from 'container/TracesExplorer/events';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { openInNewTab } from 'utils/navigation';
import styles from './OpenTraceButton.module.scss';
type OpenTraceButtonProps = {
row: TracesTableRow;
rowId: string;
};
function OpenTraceButton({
row,
rowId,
}: OpenTraceButtonProps): JSX.Element | null {
const isHovered = useIsRowHovered(rowId);
const handleClick = (event: MouseEvent): void => {
// The row click would open the drawer instead.
event.stopPropagation();
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.RowIcon);
openInNewTab(getTraceLink(row));
};
if (!isHovered) {
return null;
}
return (
<span className={styles.slot}>
<Tooltip title="Open trace details">
<Button
variant="ghost"
color="secondary"
size="sm"
icon
onClick={handleClick}
aria-label="Open trace details"
testId="span-row-open-trace"
>
<CornerUpRight size={14} />
</Button>
</Tooltip>
</span>
);
}
export default OpenTraceButton;

View File

@@ -4,7 +4,12 @@ import { ButtonGroup } from '@signozhq/ui/button-group';
import { ChevronDown, ChevronUp, CornerUpRight } from '@signozhq/icons';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { HeaderAction } from 'components/DetailsPanel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
import {
logOpenTraceClicked,
OpenTraceSource,
} from 'container/TracesExplorer/events';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
@@ -48,7 +53,10 @@ function SpanDrawer({
color="secondary"
size="md"
prefix={<CornerUpRight />}
onClick={(): void => openInNewTab(getTraceLink(row))}
onClick={(): void => {
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.SpanDrawer);
openInNewTab(getTraceLink(row));
}}
testId="span-drawer-view-full-trace"
>
View full trace

View File

@@ -1,20 +1,18 @@
import { act, renderHook } from 'tests/test-utils';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useSpanDrawer } from '../useSpanDrawer';
let mockQueryId = 'query-1';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ stagedQuery: { id: mockQueryId } }),
}));
const rows: TracesTableRow[] = [{ id: 'span-a' }, { id: 'span-b' }];
describe('useSpanDrawer', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
mockQueryId = 'query-1';
});
it('does not reopen a span that a refetch dropped and a later one brings back', () => {
@@ -32,4 +30,15 @@ describe('useSpanDrawer', () => {
rerender({ current: rows });
expect(result.current.drawerProps?.row).toBeNull();
});
it('closes on a new query while the old rows are still shown', () => {
const { result, rerender } = renderHook(() => useSpanDrawer(rows));
act(() => result.current.tableProps.onRowClick?.(rows[1]));
expect(result.current.drawerProps?.row).toBe(rows[1]);
mockQueryId = 'query-2';
rerender();
expect(result.current.drawerProps?.row).toBeNull();
});
});

View File

@@ -0,0 +1,18 @@
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import OpenTraceButton from './OpenTraceButton';
export const OPEN_TRACE_COLUMN: TableColumnDef<TracesTableRow> = {
id: 'open-trace',
header: '',
pin: 'left',
enableMove: false,
enableResize: false,
enableRemove: false,
canBeHidden: false,
width: { fixed: 32 },
cell: ({ row, rowId }): JSX.Element => (
<OpenTraceButton row={row} rowId={rowId} />
),
};

View File

@@ -1,11 +1,20 @@
import { PointerEvent, useCallback, useEffect, useMemo, useState } from 'react';
import {
PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { useRowNavigation } from 'components/TanStackTableView/useRowNavigation';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import type { TracesTableProps } from 'container/TracesExplorer/TracesTable/TracesTable';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCloseOnOutsidePress } from 'hooks/useCloseOnOutsidePress';
import { OPEN_TRACE_COLUMN } from './openTraceColumn';
import type { SpanDrawerProps } from './SpanDrawer';
import { isSpanDrawerEnabled } from './utils';
type ActiveSpan = { id: string; index: number };
@@ -14,14 +23,14 @@ type UseSpanDrawerResult = {
TracesTableProps,
'onRowClick' | 'isRowActive' | 'activeRowIndex'
>;
/** Null while the drawer flag is off. */
drawerProps: SpanDrawerProps | null;
/** Leading column that opens the trace page. */
openTraceColumn: TableColumnDef<TracesTableRow>;
drawerProps: SpanDrawerProps;
/** `onPointerDownCapture` for the area a press keeps the drawer open in. */
onPressInside: (event: PointerEvent) => void;
};
export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
const [isEnabled] = useState(isSpanDrawerEnabled);
// The index lets navigation step past a duplicate span; the id survives a refetch.
const [activeSpan, setActiveSpan] = useState<ActiveSpan | null>(null);
@@ -60,8 +69,19 @@ export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
}
}, [activeSpan, activeRowIndex]);
// A new query closes the drawer at once instead of waiting for its rows.
const { stagedQuery } = useQueryBuilder();
const queryId = stagedQuery?.id;
const lastQueryIdRef = useRef(queryId);
useEffect(() => {
if (lastQueryIdRef.current && lastQueryIdRef.current !== queryId) {
setActiveSpan(null);
}
lastQueryIdRef.current = queryId;
}, [queryId]);
const onPressInside = useCloseOnOutsidePress(
isEnabled && activeRowIndex !== -1,
activeRowIndex !== -1,
clearActiveSpan,
);
@@ -71,12 +91,13 @@ export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
onNavigate: handleNavigate,
});
if (!isEnabled) {
return { tableProps: {}, drawerProps: null, onPressInside };
}
return {
tableProps: { onRowClick: selectRow, isRowActive, activeRowIndex },
tableProps: {
onRowClick: selectRow,
isRowActive,
activeRowIndex,
},
openTraceColumn: OPEN_TRACE_COLUMN,
drawerProps: {
row: rows[activeRowIndex] ?? null,
onClose: clearActiveSpan,

View File

@@ -1,12 +1,6 @@
import get from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { SpanV3 } from 'types/api/trace/getTraceV3';
/** Off by default; set `TRACES_SPAN_DRAWER_ENABLED` to `true` in localStorage to try it. */
export const isSpanDrawerEnabled = (): boolean =>
get(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED) === 'true';
type ListSpanEvent = {
name: string;
timeUnixNano: number;

View File

@@ -0,0 +1,4 @@
.emptyValue {
display: block;
color: var(--l2-border);
}

View File

@@ -1,9 +1,15 @@
import { AppLink } from 'lib/router/AppLink';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import { Minus } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
logOpenTraceClicked,
OpenTraceSource,
} from 'container/TracesExplorer/events';
import { getMs } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
@@ -15,12 +21,15 @@ import {
} from './constants';
import { stringifyCellValue } from './utils';
import styles from './FieldCell.module.scss';
type FieldCellProps = {
name: string;
value: unknown;
panelType: PANEL_TYPES;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
function FieldCell({ name, value, panelType }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
@@ -37,7 +46,11 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
return (
<TanStackTable.Text data-testid={name}>
<Minus size={14} className={styles.emptyValue} data-testid="empty-value" />
</TanStackTable.Text>
);
}
const text = stringifyCellValue(value);
@@ -47,7 +60,15 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
<AppLink
to={buildRoutePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
onClick={(e): void => {
e.stopPropagation();
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
}}
onAuxClick={(e): void => {
if (e.button === 1) {
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
}
}}
>
{text}
</AppLink>

View File

@@ -6,21 +6,7 @@
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-height: 30px;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l3-background-60);
}

View File

@@ -1,19 +1,30 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { useAppNavigate } from 'lib/router/useAppNavigate';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import { useRecoverFromEmptyPage } from 'components/TanStackTableView/useRecoverFromEmptyPage';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import {
logOpenTraceClicked,
OpenTraceSource,
} from 'container/TracesExplorer/events';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { noop } from 'lodash-es';
import { getAbsoluteUrl } from 'utils/basePath';
import { TRACES_TABLE_QUERY_PARAMS } from './constants';
import type { TracesTableRow } from './getFieldColumn';
import type { TracesTablePagination } from './useTracesTablePagination';
import { getRollingTotal, getRowKey } from './utils';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
@@ -36,6 +47,9 @@ export type TracesTableProps = {
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
pagination?: TracesTablePagination;
/** Loads the next page when the scroll reaches the last row; hides the pager. */
onEndReached?: () => void;
};
function TracesTable({
@@ -56,9 +70,33 @@ function TracesTable({
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
pagination,
onEndReached,
}: TracesTableProps): JSX.Element {
const navigate = useAppNavigate();
// While the next page loads, `data` is still the previous page, so its rows
// cannot size the total for the new offset.
const settledTotalRef = useRef(0);
if (pagination && !isFetching) {
settledTotalRef.current = getRollingTotal(
pagination.request.offset,
data.length,
pagination.limit,
);
}
const total = settledTotalRef.current;
useRecoverFromEmptyPage({
page: pagination?.page ?? 1,
pageSize: pagination?.limit ?? 0,
rowCount: data.length,
total,
isFetching: isLoading || isFetching,
isDisabled: !pagination || isError,
setPage: pagination?.setPage ?? noop,
});
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
@@ -68,16 +106,18 @@ function TracesTable({
onRowClick(row);
return;
}
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
navigate(getRowHref(row));
},
[onRowClick, navigate, getRowHref],
[onRowClick, navigate, getRowHref, panelType],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
[getRowHref, panelType],
);
return (
@@ -110,8 +150,19 @@ function TracesTable({
onRowClickNewTab={handleRowClickNewTab}
isRowActive={isRowActive}
activeRowIndex={activeRowIndex}
getRowKey={getRowKey}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
testId={`traces-table-${panelType.toLowerCase()}`}
onEndReached={onEndReached}
enableQueryParams={pagination ? TRACES_TABLE_QUERY_PARAMS : undefined}
pagination={
pagination && {
total,
defaultLimit: pagination.limit,
showTotalCount: false,
onLimitChange: pagination.setLimit,
}
}
/>
</div>
)}
@@ -128,6 +179,8 @@ TracesTable.defaultProps = {
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
pagination: undefined,
onEndReached: undefined,
};
export default TracesTable;

View File

@@ -0,0 +1,43 @@
import { renderHook } from 'tests/test-utils';
import { useKeepRowsForSameQuery } from '../useKeepRowsForSameQuery';
let mockQueryId = 'query-1';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ stagedQuery: { id: mockQueryId } }),
}));
describe('useKeepRowsForSameQuery', () => {
beforeEach(() => {
mockQueryId = 'query-1';
});
it('keeps the old rows while the same query refetches', () => {
const { result, rerender } = renderHook(
({ isPreviousData }: { isPreviousData: boolean }) =>
useKeepRowsForSameQuery(isPreviousData),
{ initialProps: { isPreviousData: false } },
);
rerender({ isPreviousData: true });
expect(result.current).toBe(true);
});
it('drops the old rows once a new query runs, until its rows land', () => {
const { result, rerender } = renderHook(
({ isPreviousData }: { isPreviousData: boolean }) =>
useKeepRowsForSameQuery(isPreviousData),
{ initialProps: { isPreviousData: false } },
);
mockQueryId = 'query-2';
rerender({ isPreviousData: true });
expect(result.current).toBe(false);
rerender({ isPreviousData: false });
expect(result.current).toBe(true);
rerender({ isPreviousData: true });
expect(result.current).toBe(true);
});
});

View File

@@ -0,0 +1,72 @@
import { ReactNode } from 'react';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import { renderHook, waitFor } from 'tests/test-utils';
import { useTracesTablePagination } from '../useTracesTablePagination';
let mockQueryId = 'query-1';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ stagedQuery: { id: mockQueryId } }),
}));
const renderPagination = (
searchParams: Record<string, string>,
): {
offsets: number[];
urlUpdates: UrlUpdateEvent[];
rerender: () => void;
} => {
const offsets: number[] = [];
const urlUpdates: UrlUpdateEvent[] = [];
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={(event): void => {
urlUpdates.push(event);
}}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
const { rerender } = renderHook(
() => {
const pagination = useTracesTablePagination();
offsets.push(pagination.request.offset);
return pagination;
},
{ wrapper },
);
return { offsets, urlUpdates, rerender };
};
describe('useTracesTablePagination', () => {
beforeEach(() => {
mockQueryId = 'query-1';
});
it('requests the page in the URL for the query it was picked on', () => {
const { offsets, rerender } = renderPagination({ page: '3', limit: '10' });
rerender();
expect(new Set(offsets)).toStrictEqual(new Set([20]));
});
it('requests the first page as soon as a new query runs, then resets the URL', async () => {
const { offsets, urlUpdates, rerender } = renderPagination({
page: '3',
limit: '10',
});
const before = offsets.length;
mockQueryId = 'query-2';
rerender();
expect(offsets.slice(before)).not.toContain(20);
// Page 1 is the default, so the URL drops the param.
await waitFor(() =>
expect(urlUpdates.at(-1)?.searchParams.has('page')).toBe(false),
);
});
});

View File

@@ -16,3 +16,12 @@ export const STATUS_FIELD_NAMES = new Set([
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
export const TRACES_TABLE_QUERY_PARAMS = {
page: 'page',
limit: 'limit',
} as const;
export const TRACES_TABLE_DEFAULT_LIMIT = 20;
export const TRACES_TABLE_PAGE_SIZE_KEY = 'traces-explorer';

View File

@@ -2,6 +2,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
@@ -10,6 +11,7 @@ export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
panelType: PANEL_TYPES,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext, fieldDataType } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
@@ -25,6 +27,8 @@ export function getFieldColumn(
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
cell: ({ value }): JSX.Element => (
<FieldCell name={name} value={value} panelType={panelType} />
),
};
}

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
/**
* Whether rows kept from the previous request may stay on screen: yes while the
* same query refetches (refresh, next page), no once a new query runs, so the
* table shows its loader instead of the old query's rows.
*/
export function useKeepRowsForSameQuery(isPreviousData: boolean): boolean {
const { stagedQuery } = useQueryBuilder();
const queryId = stagedQuery?.id;
const [loadedQueryId, setLoadedQueryId] = useState(queryId);
useEffect(() => {
if (!isPreviousData) {
setLoadedQueryId(queryId);
}
}, [isPreviousData, queryId]);
return !isPreviousData || loadedQueryId === queryId;
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import {
SetPageOptions,
useTableParams,
} from 'components/TanStackTableView/useTableParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import {
TRACES_TABLE_DEFAULT_LIMIT,
TRACES_TABLE_PAGE_SIZE_KEY,
TRACES_TABLE_QUERY_PARAMS,
} from './constants';
export type TracesTablePagination = {
page: number;
limit: number;
request: Pagination;
setPage: (page: number, options?: SetPageOptions) => void;
setLimit: (limit: number) => void;
};
export function useTracesTablePagination(): TracesTablePagination {
const { page, limit, setPage, setLimit } = useTableParams(
TRACES_TABLE_QUERY_PARAMS,
{
page: 1,
limit: TRACES_TABLE_DEFAULT_LIMIT,
storageKey: TRACES_TABLE_PAGE_SIZE_KEY,
},
);
const { stagedQuery } = useQueryBuilder();
const queryId = stagedQuery?.id;
const [pageQueryId, setPageQueryId] = useState(queryId);
// A page number only means something for the query it was picked on, so a
// new query reads page 1 before the URL catches up.
const isNewQuery = !!pageQueryId && pageQueryId !== queryId;
const currentPage = isNewQuery ? 1 : page;
useEffect(() => {
if (isNewQuery) {
setPage(1, { history: 'replace' });
}
setPageQueryId(queryId);
}, [isNewQuery, queryId, setPage]);
const request = useMemo(
() => ({ offset: (currentPage - 1) * limit, limit }),
[currentPage, limit],
);
return { page: currentPage, limit, request, setPage, setLimit };
}

View File

@@ -1,3 +1,5 @@
import type { TracesTableRow } from './getFieldColumn';
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
@@ -10,3 +12,20 @@ export function stringifyCellValue(value: unknown): string {
}
return JSON.stringify(value);
}
/**
* The list API returns no total, so a full page counts one row past itself:
* the pager offers the next page only while the last one came back full.
*/
export function getRollingTotal(
offset: number,
rowCount: number,
limit: number,
): number {
return offset + rowCount + (rowCount >= limit ? 1 : 0);
}
/** The API can return the same span twice; the table suffixes repeated keys so each row hovers on its own. */
export function getRowKey(row: TracesTableRow): string {
return row.id;
}

View File

@@ -10,6 +10,16 @@
.actionsContainer {
display: flex;
min-height: 40px;
padding: 4px 8px;
border-bottom: 1px solid var(--l1-border);
justify-content: space-between;
align-items: center;
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
}

View File

@@ -0,0 +1,93 @@
import { logEventMock } from '__tests__/logEventMock';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
OpenTraceSource,
TracesExplorerEvents,
} from 'container/TracesExplorer/events';
import { getCurrentLocation } from 'lib/router/navigation';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
groupedResponse,
groupedRows,
QUERY_RANGE_URL,
renderTracesView,
} from './testUtils';
const TRACE_ID = '0000000000000000344ded1387b08a7e';
const ROW_TEST_ID = `traces-table-row-${TRACE_ID}`;
describe('TracesView - open trace clicked', () => {
beforeEach(() => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(groupedResponse(groupedRows))),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('logs a row click that opens the trace page', async () => {
const user = userEvent.setup();
renderTracesView();
const row = await screen.findByTestId(ROW_TEST_ID);
// The row click handler sits on each cell, so a click on the <tr> does nothing.
await user.click(within(row).getAllByRole('cell')[0]);
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(`/trace/${TRACE_ID}`),
);
expect(logEventMock).toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{
panelType: PANEL_TYPES.TRACE,
source: OpenTraceSource.Row,
},
);
});
it('logs a trace_id link click', async () => {
const user = userEvent.setup();
renderTracesView();
const row = await screen.findByTestId(ROW_TEST_ID);
await user.click(within(row).getByTestId('trace-id'));
expect(logEventMock).toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{
panelType: PANEL_TYPES.TRACE,
source: OpenTraceSource.TraceId,
},
);
expect(logEventMock).not.toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{ panelType: PANEL_TYPES.TRACE, source: OpenTraceSource.Row },
);
});
it('logs a middle click on the trace_id link', async () => {
const user = userEvent.setup();
renderTracesView();
const row = await screen.findByTestId(ROW_TEST_ID);
await user.pointer({
keys: '[MouseMiddle]',
target: within(row).getByTestId('trace-id'),
});
expect(logEventMock).toHaveBeenCalledWith(
TracesExplorerEvents.OpenTraceClicked,
{
panelType: PANEL_TYPES.TRACE,
source: OpenTraceSource.TraceId,
},
);
});
});

View File

@@ -0,0 +1,84 @@
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
groupedResponse,
groupedRows,
QUERY_RANGE_URL,
renderTracesView,
} from './testUtils';
// Answers each offset with `rowsPerOffset[offset]` rows and records the offsets asked for.
// An offset in `held` answers once its promise resolves.
const mockPages = (
rowsPerOffset: Record<number, number>,
held?: Record<number, Promise<void>>,
): number[] => {
const offsets: number[] = [];
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
const body = (await req.json()) as QueryRangeRequestV5;
const spec = body.compositeQuery.queries[0].spec as { offset?: number };
const offset = spec.offset ?? 0;
offsets.push(offset);
await held?.[offset];
const rows = Array.from({ length: rowsPerOffset[offset] ?? 0 }, (_, i) => ({
...groupedRows[0],
data: { ...groupedRows[0].data, trace_id: `trace-${offset + i}` },
}));
return res(ctx.status(200), ctx.json(groupedResponse(rows)));
}),
);
return offsets;
};
describe('TracesView pagination', () => {
afterEach(() => {
server.resetHandlers();
});
it('offers the next page after a full page and requests its offset', async () => {
const offsets = mockPages({ 0: 20, 20: 3 });
const user = userEvent.setup();
renderTracesView();
const next = await screen.findByRole('button', { name: 'Go to next page' });
await waitFor(() => expect(next).toBeEnabled());
await user.click(next);
await expect(screen.findByText('trace-22')).resolves.toBeInTheDocument();
expect(offsets).toStrictEqual([0, 20]);
});
it('offers no page past the next one until that page arrives', async () => {
let release = (): void => {};
const pageTwo = new Promise<void>((resolve) => {
release = resolve;
});
mockPages({ 0: 20, 20: 20 }, { 20: pageTwo });
const user = userEvent.setup();
renderTracesView();
const next = await screen.findByRole('button', { name: 'Go to next page' });
await waitFor(() => expect(next).toBeEnabled());
await user.click(next);
const pager = screen.getByLabelText('pagination');
expect(within(pager).queryByText('3')).not.toBeInTheDocument();
release();
await expect(within(pager).findByText('3')).resolves.toBeInTheDocument();
});
it('offers no next page after a short page', async () => {
mockPages({ 0: 3 });
renderTracesView();
await screen.findAllByText('frontend');
expect(
screen.queryByRole('button', { name: 'Go to next page' }),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import TracesView from '../index';
export const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
export const groupedRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
// intentionally empty to assert the "-" cell
name: '',
duration_nano: 790949390,
span_count: 3,
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
},
},
];
export const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
});
export const renderTracesView = (
props: Record<string, unknown> = {},
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
{...props}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
} as any,
},
);

View File

@@ -1,12 +1,10 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },
@@ -18,7 +16,7 @@ const TRACE_FIELDS = [
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
(field) => ({
...getFieldColumn(field),
...getFieldColumn(field, PANEL_TYPES.TRACE),
enableRemove: false,
canBeHidden: false,
}),

View File

@@ -1,42 +1,13 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from 'tests/test-utils';
import { screen, waitFor } from 'tests/test-utils';
import TracesView from './index';
const BASE_URL = ENVIRONMENT.baseURL;
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
const groupedRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
// intentionally empty to assert the "-" cell
name: '',
duration_nano: 790949390,
span_count: 3,
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
},
},
];
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
});
import {
groupedResponse,
groupedRows,
QUERY_RANGE_URL,
renderTracesView,
} from './__tests__/testUtils';
const mockSuccess = (rows: unknown[] = groupedRows): void => {
server.use(
@@ -54,29 +25,6 @@ const mockError = (): void => {
);
};
const renderTracesView = (
props: Record<string, unknown> = {},
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
{...props}
/>
</VirtuosoMockContext.Provider>,
{},
{
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
} as any,
},
);
describe('TracesView (grouped root-span table)', () => {
afterEach(() => {
server.resetHandlers();
@@ -96,8 +44,8 @@ describe('TracesView (grouped root-span table)', () => {
// span_count renders as text
expect(screen.getByText('8')).toBeInTheDocument();
// empty field renders "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
// empty field renders the dash icon
expect(screen.getAllByTestId('empty-value').length).toBeGreaterThanOrEqual(1);
// trace_id renders as a link to the trace detail
const traceLinks = screen.getAllByTestId('trace-id');
@@ -115,8 +63,6 @@ describe('TracesView (grouped root-span table)', () => {
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
@@ -130,7 +76,5 @@ describe('TracesView (grouped root-span table)', () => {
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
});
});

View File

@@ -14,25 +14,23 @@ import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { useKeepRowsForSameQuery } from 'container/TracesExplorer/TracesTable/useKeepRowsForSameQuery';
import { useTracesTablePagination } from 'container/TracesExplorer/TracesTable/useTracesTablePagination';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import TraceExplorerControls from '../Controls';
import { columns, PER_PAGE_OPTIONS } from './configs';
import { columns } from './configs';
import styles from './TracesView.module.scss';
@@ -59,9 +57,7 @@ function TracesView({
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const pagination = useTracesTablePagination();
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
@@ -76,7 +72,7 @@ function TracesView({
minTime,
stagedQuery,
panelType,
paginationQueryData,
pagination.request,
],
[
globalSelectedTime,
@@ -84,7 +80,7 @@ function TracesView({
minTime,
stagedQuery,
panelType,
paginationQueryData,
pagination.request,
],
);
@@ -92,25 +88,27 @@ function TracesView({
queryKeyRef.current = queryKey;
}
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: transformedQuery,
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
const { data, isLoading, isFetching, isError, error, isPreviousData } =
useGetQueryRange(
{
query: transformedQuery,
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: pagination.request,
},
},
tableParams: {
pagination: paginationQueryData,
ENTITY_VERSION_V5,
{
queryKey,
keepPreviousData: true,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
},
ENTITY_VERSION_V5,
{
queryKey,
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
);
);
useEffect(() => {
if (data?.payload) {
@@ -119,7 +117,10 @@ function TracesView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
const keepRows = useKeepRowsForSameQuery(isPreviousData);
const responseData = keepRows
? data?.payload?.data?.newResult?.data?.result[0]?.list
: undefined;
const rows = useMemo<TracesTableRow[]>(
() =>
@@ -157,18 +158,12 @@ function TracesView({
</Typography.Link>
</Typography>
<div className="trace-explorer-controls">
<div className={styles.controls}>
{headerActions}
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={rows.length}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
</div>
@@ -184,6 +179,7 @@ function TracesView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
pagination={pagination}
/>
</div>
);

View File

@@ -0,0 +1,20 @@
import logEvent from 'api/common/logEvent';
import { PANEL_TYPES } from 'constants/queryBuilder';
export enum TracesExplorerEvents {
OpenTraceClicked = 'Traces Explorer: Open trace clicked',
}
export enum OpenTraceSource {
Row = 'row',
RowIcon = 'row_icon',
TraceId = 'trace_id',
SpanDrawer = 'span_drawer',
}
export function logOpenTraceClicked(
panelType: PANEL_TYPES,
source: OpenTraceSource,
): void {
void logEvent(TracesExplorerEvents.OpenTraceClicked, { panelType, source });
}

View File

@@ -0,0 +1,25 @@
import GetMinMax from './getMinMax';
const DAY_NS = 24 * 60 * 60 * 1000 * 1e6;
function rangeInDays(interval: string): number {
const { minTime, maxTime } = GetMinMax(interval);
return Math.round((maxTime - minTime) / DAY_NS);
}
describe('GetMinMax', () => {
it.each([
['1month', 30],
['2months', 60],
['3months', 90],
['6month', 180],
['10d', 10],
['3w', 21],
])('resolves %p to %p days', (interval, days) => {
expect(rangeInDays(interval)).toBe(days);
});
it('throws on an unknown interval', () => {
expect(() => GetMinMax('1y')).toThrow('invalid time type');
});
});

View File

@@ -14,7 +14,7 @@ export const isValidShortHandDateTimeFormat = (time: string): boolean =>
const extractTimeAndUnit = (time: string): { time: number; unit: string } => {
// Match the pattern
const match = /^(\d+)([mhdw])$/.exec(time);
const match = validCustomTimeRegex.exec(time);
if (match) {
return { time: parseInt(match[1], 10), unit: match[2] };
@@ -39,13 +39,16 @@ export const getMinTimeForRelativeTimes = (
return getMinAgo({ minutes: 24 * 60 * time }).getTime();
case 'w':
return getMinAgo({ minutes: 24 * 60 * 7 * time }).getTime();
case 'month':
case 'months':
return getMinAgo({ minutes: 24 * 60 * 30 * time }).getTime();
default:
return getMinAgo({ minutes: 1 }).getTime();
}
};
const GetMinMax = (
interval: Time | string,
interval: string,
dateTimeRange?: [number, number],
// eslint-disable-next-line sonarjs/cognitive-complexity
): GetMinMaxPayload => {

View File

@@ -28,6 +28,23 @@
}
}
.itemButton {
display: block;
width: 100%;
padding: 0;
border: none;
border-radius: var(--radius);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}
.listLightMode {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);

View File

@@ -1,4 +1,10 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
useCallback,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -22,11 +28,14 @@ const LIST_VERTICAL_PADDING = 16;
interface TooltipListProps {
id: string;
content: TooltipContentItem[];
/** Makes each row a button. */
onItemClick?: (index: number, event: ReactMouseEvent) => void;
}
export default function TooltipList({
id,
content,
onItemClick,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const isScrollEventTriggered = useRef(false);
@@ -62,9 +71,23 @@ export default function TooltipList({
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
)}
itemContent={(index, item): JSX.Element => {
const row = (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
);
return onItemClick ? (
<button
type="button"
className={Styles.itemButton}
data-testid="uplot-tooltip-item-button"
onClick={(event): void => onItemClick(index, event)}
>
{row}
</button>
) : (
row
);
}}
/>
</div>
);

View File

@@ -26,34 +26,7 @@
}
.pieChartTooltip {
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
background-color: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.pieChartTooltipContent {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.pieChartIndicator {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
display: inline-block;
}
.pieChartTooltipValue {
font-weight: bold;
margin-top: 4px;
}
// width/height are set inline from the computed chart dimensions; border-box

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie as VisxPie } from '@visx/shape';
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { useTooltipInPortal } from '@visx/tooltip';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -14,14 +14,15 @@ import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import PieArc from 'lib/visualization/charts/Pie/components/PieArc/PieArc';
import { groupPieSlices } from 'lib/visualization/charts/Pie/utils/groupPieSlices';
import PieCenterLabel from 'lib/visualization/charts/Pie/components/PieCenterLabel/PieCenterLabel';
import PieTooltipContent from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltipContent';
import styles from 'lib/visualization/charts/Pie/Pie.module.scss';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import {
getDonutGeometry,
getFillColor,
} from 'lib/visualization/charts/Pie/utils';
import { usePieTooltip } from 'lib/visualization/charts/Pie/hooks/usePieTooltip';
import { getFillColor } from 'lib/visualization/charts/Pie/utils/colors';
import { getDonutGeometry } from 'lib/visualization/charts/Pie/utils/geometry';
import { compareSlices } from 'lib/visualization/charts/Pie/utils/slices';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -56,20 +57,29 @@ export default function Pie({
[data, hiddenKeys],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
// Hidden slices drop out before grouping, so Other and the centre total recompute.
const visibleData = useMemo(
() => data.filter((slice) => !hiddenKeys.has(slice.label)),
[data, hiddenKeys],
() =>
groupPieSlices(
data.filter((slice) => !hiddenKeys.has(slice.label)),
isDarkMode,
),
[data, hiddenKeys, isDarkMode],
);
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<PieTooltipData>();
// A series folded into Other highlights the Other arc.
const drawnColorByLabel = useMemo(
() =>
new Map(
visibleData.flatMap((slice) =>
(slice.groupedMembers ?? [slice]).map((entry): [string, string] => [
entry.label,
slice.color,
]),
),
),
[visibleData],
);
const { containerRef, TooltipInPortal } = useTooltipInPortal({
scroll: true,
@@ -112,34 +122,64 @@ export default function Pie({
[visibleData],
);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor =
focusedSeriesIndex !== null ? data[focusedSeriesIndex].color : null;
const handleSliceEnter = useCallback(
(slice: PieSlice, centroidX: number, centroidY: number): void => {
showTooltip({
tooltipData: {
label: slice.label,
value: getYAxisFormattedValue(
slice.value.toString(),
yAxisUnit || 'none',
decimalPrecision,
),
color: slice.color,
},
tooltipTop: centroidY + height / 2,
tooltipLeft: centroidX + width / 2,
});
setFocusedKey(slice.label);
},
[showTooltip, setFocusedKey, yAxisUnit, decimalPrecision, height, width],
const formatValue = useCallback(
(value: number): string =>
getYAxisFormattedValue(
value.toString(),
yAxisUnit || 'none',
decimalPrecision,
),
[yAxisUnit, decimalPrecision],
);
const handleSliceLeave = useCallback((): void => {
hideTooltip();
setFocusedKey(null);
}, [hideTooltip, setFocusedKey]);
const {
tooltipOpen,
tooltipTop,
tooltipLeft,
tooltipData,
isPinned,
unpin,
handleSliceHover,
handleSliceLeave,
handleSliceClick,
handleMemberClick,
} = usePieTooltip({
slices: visibleData,
chartRef: wrapperRef,
formatValue,
setFocusedKey,
onSliceClick,
});
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const hoveredColor = tooltipOpen ? tooltipData?.slice.color : undefined;
const activeColor =
hoveredColor ??
(focusedSeriesIndex !== null
? (drawnColorByLabel.get(labels[focusedSeriesIndex]) ?? null)
: null);
// The tooltip re-renders Pie on every mouse move; a stable element lets React skip the legend.
const legend = useMemo(
() => (
<Legend
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showLegendSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
),
[
legendItems,
position,
averageLegendWidth,
showLegendSearch,
focusedSeriesIndex,
onLegendAction,
],
);
if (!data.length) {
return (
@@ -169,6 +209,7 @@ export default function Pie({
<VisxPie
data={visibleData}
pieValue={(slice: PieSlice): number => slice.value}
pieSort={compareSlices}
outerRadius={radius}
innerRadius={innerRadius}
padAngle={0.01}
@@ -193,9 +234,9 @@ export default function Pie({
decimalPrecision={decimalPrecision}
labelColor={labelColor}
fill={getFillColor(arc.data.color, activeColor)}
onEnter={handleSliceEnter}
onHover={handleSliceHover}
onLeave={handleSliceLeave}
onClick={onSliceClick}
onClick={handleSliceClick}
/>
))
}
@@ -216,19 +257,19 @@ export default function Pie({
top={tooltipTop}
left={tooltipLeft}
className={styles.pieChartTooltip}
style={{
...defaultStyles,
color: labelColor,
}}
unstyled
applyPositionStyle
style={{ pointerEvents: isPinned ? 'auto' : 'none' }}
data-pie-tooltip=""
>
<div
className={styles.pieChartIndicator}
style={{ background: tooltipData.color }}
<PieTooltipContent
id={id ?? ''}
data={tooltipData}
isPinned={isPinned}
canDrilldown={!!onSliceClick}
onMemberClick={handleMemberClick}
onUnpin={unpin}
/>
<div className={styles.pieChartTooltipContent}>
<span>{tooltipData.label}</span>
<span className={styles.pieChartTooltipValue}>{tooltipData.value}</span>
</div>
</TooltipInPortal>
)}
</div>
@@ -241,14 +282,7 @@ export default function Pie({
height: legendHeight,
}}
>
<Legend
items={legendItems}
position={position}
averageLegendWidth={averageLegendWidth}
showSearch={showLegendSearch}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
{legend}
</div>
</div>
);

View File

@@ -3,8 +3,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieSlice } from 'lib/visualization/charts/types';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import Pie from 'lib/visualization/charts/Pie/Pie';
import { userEvent } from 'tests/test-utils';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
@@ -14,22 +15,43 @@ jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn((value: string) => value),
}));
// VirtuosoGrid only renders a window in jsdom; render every item so we can
// assert on legend entries.
let mockLegendRenders = 0;
// Virtuoso only renders a window in jsdom; render every item so we can
// assert on legend entries and tooltip rows.
jest.mock('react-virtuoso', () => ({
Virtuoso: ({
data,
itemContent,
'data-testid': testId,
}: {
data: unknown[];
itemContent: (index: number, item: unknown) => React.ReactNode;
'data-testid'?: string;
}): JSX.Element => (
<div data-testid={testId}>
{data.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index}>{itemContent(index, item)}</div>
))}
</div>
),
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
),
}): JSX.Element => {
mockLegendRenders += 1;
return (
<div data-testid="virtuoso-grid">
{data.map((item, index) => (
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
))}
</div>
);
},
}));
const DATA: PieSlice[] = [
@@ -40,10 +62,16 @@ const DATA: PieSlice[] = [
function renderPie(
props: Partial<React.ComponentProps<typeof Pie>> = {},
): void {
render(<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />);
): ReturnType<typeof render> {
return render(
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />,
);
}
const mockSliceClick = (): jest.MockedFunction<
NonNullable<PieChartProps['onSliceClick']>
> => jest.fn();
describe('Pie', () => {
it('renders the "No data" state for empty data', () => {
render(<Pie data={[]} isDarkMode={false} data-testid="pie" />);
@@ -124,4 +152,224 @@ describe('Pie', () => {
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('hints at drill-down on a plain slice only when clickable', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { unmount } = renderPie({ onSliceClick: mockSliceClick() });
const arc = (): Element =>
screen.getByTestId('pie').querySelectorAll('svg g g')[0];
await user.hover(arc());
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
unmount();
renderPie();
await user.hover(arc());
expect(screen.queryByTestId('pie-tooltip-footer')).toBeNull();
});
it('does not re-render the legend while the tooltip follows the cursor', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPie();
const arc = screen.getByTestId('pie').querySelectorAll('svg g g')[0];
await user.hover(arc);
const rendersAfterEnter = mockLegendRenders;
// user-event leaves clientX/Y at 0 in jsdom, so cursor moves go through fireEvent.
fireEvent.mouseMove(arc, { clientX: 20, clientY: 20 });
fireEvent.mouseMove(arc, { clientX: 30, clientY: 30 });
expect(mockLegendRenders).toBe(rendersAfterEnter);
});
describe('grouped slice', () => {
// Each member is under 1% of the 2078 total, so the 12 fold into Other.
const members: PieSlice[] = Array.from({ length: 12 }, (_, index) => ({
label: `svc-${index}`,
value: 12 - index,
color: '#123456',
}));
const GROUPED: PieSlice[] = [
{ label: 'frontend', value: 1000, color: '#aa0000' },
{ label: 'cart', value: 600, color: '#00aa00' },
{ label: 'checkout', value: 400, color: '#0000aa' },
...members,
];
const arcs = (testId = 'pie'): NodeListOf<SVGPathElement> =>
(
screen.getByTestId(testId).querySelector('svg') as SVGElement
).querySelectorAll('path');
const arcGroups = (testId = 'pie'): NodeListOf<SVGGElement> =>
(
screen.getByTestId(testId).querySelector('svg') as SVGElement
).querySelectorAll('g g');
const tooltipList = (): HTMLElement =>
screen.getByTestId('uplot-tooltip-list');
const tooltip = (): HTMLElement =>
document.querySelector('[data-pie-tooltip]') as HTMLElement;
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup({ pointerEventsCheck: 0 });
});
const hoverOther = async (testId = 'pie'): Promise<SVGGElement> => {
const otherArc = arcGroups(testId)[3];
await user.hover(otherArc);
return otherArc;
};
it('draws one Other arc but lists every series in the legend', () => {
renderPie({ data: GROUPED });
expect(arcs()).toHaveLength(4);
expect(screen.getByText('Other (12)')).toBeInTheDocument();
const legend = screen.getByTestId('virtuoso-grid');
expect(within(legend).getByText('svc-11')).toBeInTheDocument();
expect(within(legend).queryByText(/^Other/)).toBeNull();
});
it('highlights the Other arc when a series inside it is hovered in the legend', async () => {
renderPie({ data: GROUPED });
await user.hover(screen.getByTestId('legend-item-5'));
const fills = [...arcs()].map((arc) => arc.getAttribute('fill'));
expect(fills[0]).toBe('rgba(170, 0, 0, 0.4)');
expect(fills[3]).not.toMatch(/^rgba/);
});
it('highlights a real series named Other, not the grouped slice', async () => {
renderPie({
data: [
...GROUPED.slice(0, 3),
{ label: 'Other', value: 500, color: '#00aaaa' },
...members,
],
});
await user.hover(screen.getByTestId('legend-item-3'));
const fills = [...arcs()].map((arc) => arc.getAttribute('fill'));
expect(fills[3]).toBe('#00aaaa');
expect(fills[fills.length - 1]).toMatch(/^rgba/);
});
it('regroups Other from the series left showing', async () => {
renderPie({ data: GROUPED });
// Isolate one member, then add a second back: Other can't hold just one.
await user.click(screen.getByTestId('legend-item-3'));
expect(arcs()).toHaveLength(1);
await user.click(screen.getByTestId('legend-item-0'));
await user.click(screen.getByTestId('legend-item-4'));
expect(arcs()).toHaveLength(3);
expect(screen.queryByText(/^Other/)).toBeNull();
});
it('lists every member in the hover tooltip, not yet clickable', async () => {
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
await hoverOther();
expect(tooltipList().children).toHaveLength(12);
expect(within(tooltipList()).getByText('svc-11')).toBeInTheDocument();
expect(
screen.getByText('Click to pin, then pick a series'),
).toBeInTheDocument();
expect(screen.queryByTestId('uplot-tooltip-item-button')).toBeNull();
expect(screen.queryByTestId('uplot-tooltip-status')).toBeNull();
});
it('follows the cursor until pinned', async () => {
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
const otherArc = await hoverOther();
// user-event leaves clientX/Y at 0 in jsdom, so cursor moves go through fireEvent.
fireEvent.mouseMove(otherArc, { clientX: 100, clientY: 50 });
const first = tooltip().getAttribute('style');
fireEvent.mouseMove(otherArc, { clientX: 140, clientY: 90 });
const moved = tooltip().getAttribute('style');
expect(moved).not.toBe(first);
await user.click(otherArc);
fireEvent.mouseMove(otherArc, { clientX: 10, clientY: 10 });
expect(tooltip().getAttribute('style')).toBe(moved);
});
it('pins on click, then drills into the clicked member', async () => {
const onSliceClick = mockSliceClick();
renderPie({ data: GROUPED, onSliceClick });
const otherArc = await hoverOther();
await user.click(otherArc);
await user.unhover(otherArc);
expect(onSliceClick).not.toHaveBeenCalled();
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
expect(screen.getByText('12 series')).toBeInTheDocument();
expect(screen.getByText('Click a series to drilldown')).toBeInTheDocument();
jest
.spyOn(tooltip(), 'getBoundingClientRect')
.mockReturnValue({ left: 40, top: 70 } as DOMRect);
await user.click(within(tooltipList()).getByText('svc-3'));
// The drill-down menu opens where the pinned tooltip was, not at the row.
expect(onSliceClick).toHaveBeenCalledWith(members[3], { x: 40, y: 70 });
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('unpins on Escape, a click outside and the Unpin button', async () => {
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
await user.click(await hoverOther());
await user.keyboard('{Escape}');
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
await user.click(await hoverOther());
await user.click(document.body);
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
await user.click(await hoverOther());
await user.click(screen.getByTestId('pie-tooltip-unpin'));
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('unpins when a click lands on another pie', async () => {
render(
<>
<Pie data={GROUPED} isDarkMode={false} data-testid="pie" />
<Pie data={DATA} isDarkMode={false} data-testid="other-pie" />
</>,
);
await user.click(await hoverOther());
expect(tooltipList()).toBeInTheDocument();
await user.click(arcGroups('other-pie')[0]);
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('drops the pinned tooltip when new data arrives', async () => {
const { rerender } = renderPie({ data: GROUPED });
await user.click(await hoverOther());
expect(tooltipList()).toBeInTheDocument();
rerender(<Pie data={[...GROUPED]} isDarkMode={false} data-testid="pie" />);
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
});
it('pins without drill-down targets when drill-down is off', async () => {
renderPie({ data: GROUPED });
await user.click(await hoverOther());
expect(screen.queryByTestId('uplot-tooltip-item-button')).toBeNull();
expect(screen.getByTestId('pie-tooltip-unpin')).toBeInTheDocument();
});
});
});

View File

@@ -4,7 +4,8 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { PieSlice } from 'lib/visualization/charts/types';
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils';
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils/geometry';
import { getSliceDisplayLabel } from 'lib/visualization/charts/Pie/utils/slices';
// Slices below this share of the total don't get a leader label (too cramped).
const MIN_LABEL_SHARE = 0.03;
@@ -14,7 +15,7 @@ interface PieArcProps {
slice: PieSlice;
/** SVG path `d` for the arc, from the visx pie generator. */
arcPath: string;
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
/** Arc centroid `[x, y]`, used to anchor the leader line. */
centroid: [number, number];
startAngle: number;
endAngle: number;
@@ -26,7 +27,8 @@ interface PieArcProps {
labelColor: string;
/** Resolved fill (already dimmed if another slice is active). */
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
/** Fired on enter and on every move, so the tooltip can follow the cursor. */
onHover: (slice: PieSlice, event: ReactMouseEvent<SVGGElement>) => void;
onLeave: () => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
}
@@ -34,7 +36,7 @@ interface PieArcProps {
/**
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
* out to an external label + value. Pure presentation — interaction is
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
* delegated to the `onHover`/`onLeave`/`onClick` callbacks.
*/
export default function PieArc({
slice,
@@ -48,11 +50,12 @@ export default function PieArc({
decimalPrecision,
labelColor,
fill,
onEnter,
onHover,
onLeave,
onClick,
}: PieArcProps): JSX.Element {
const { label, value } = slice;
const { value } = slice;
const label = getSliceDisplayLabel(slice);
const [centroidX, centroidY] = centroid;
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
startAngle,
@@ -71,7 +74,9 @@ export default function PieArc({
return (
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
data-pie-arc=""
onMouseEnter={(event): void => onHover(slice, event)}
onMouseMove={(event): void => onHover(slice, event)}
onMouseLeave={onLeave}
onClick={(event): void => onClick?.(slice, event)}
>

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PieSlice } from 'lib/visualization/charts/types';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieArc from 'lib/visualization/charts/Pie/components/PieArc/PieArc';
jest.mock('components/Graph/yAxisConfig', () => ({
// Echo the raw value so assertions are deterministic.
@@ -11,12 +11,12 @@ jest.mock('components/Graph/yAxisConfig', () => ({
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
onEnter: jest.Mock;
onHover: jest.Mock;
onLeave: jest.Mock;
onClick: jest.Mock;
container: HTMLElement;
} {
const onEnter = jest.fn();
const onHover = jest.fn();
const onLeave = jest.fn();
const onClick = jest.fn();
const { container } = render(
@@ -31,14 +31,14 @@ function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
totalValue={100}
labelColor="#fff"
fill="#f00"
onEnter={onEnter}
onHover={onHover}
onLeave={onLeave}
onClick={onClick}
{...props}
/>
</svg>,
);
return { onEnter, onLeave, onClick, container };
return { onHover, onLeave, onClick, container };
}
describe('PieArc', () => {
@@ -69,12 +69,14 @@ describe('PieArc', () => {
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
});
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
const { onEnter, onLeave, onClick, container } = renderArc();
it('fires onHover with the slice on enter and move, and onLeave / onClick', () => {
const { onHover, onLeave, onClick, container } = renderArc();
const g = container.querySelector('g') as SVGGElement;
fireEvent.mouseEnter(g);
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
fireEvent.mouseMove(g);
expect(onHover).toHaveBeenCalledTimes(2);
expect(onHover).toHaveBeenCalledWith(SLICE, expect.anything());
fireEvent.mouseLeave(g);
expect(onLeave).toHaveBeenCalledTimes(1);

View File

@@ -1,7 +1,7 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils';
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils/geometry';
interface PieCenterLabelProps {
/** Sum of the visible slice values, shown in the donut hole. */

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import PieCenterLabel from 'lib/visualization/charts/Pie/components/PieCenterLabel/PieCenterLabel';
jest.mock('components/Graph/yAxisConfig', () => ({
getYAxisFormattedValue: jest.fn(),

View File

@@ -0,0 +1,39 @@
// Mirrors the uPlot tooltip container (uPlotV2/components/Tooltip).
.body {
display: flex;
flex-direction: column;
width: 360px;
max-width: 80vw;
font-family: 'Inter';
font-size: 12px;
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.pinned {
border-color: var(--ring);
}
.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-8) var(--spacing-8) 0;
font-size: var(--font-size-sm);
font-weight: 500;
}
.activeItem {
padding: var(--spacing-4);
}
.divider {
display: block;
height: 1px;
background-color: var(--l2-border);
}

View File

@@ -0,0 +1,71 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import cx from 'classnames';
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
import TooltipList from 'lib/uPlotV2/components/Tooltip/components/TooltipList/TooltipList';
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import PieTooltipFooter from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltipFooter';
import styles from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltip.module.scss';
interface PieTooltipContentProps {
/** Widget id, for the list's analytics. */
id: string;
data: PieTooltipData;
/** Pinned by a click on a grouped slice: its members become drill-down targets. */
isPinned: boolean;
canDrilldown: boolean;
onMemberClick: (index: number, event: ReactMouseEvent) => void;
onUnpin: () => void;
}
/** Hovered slice's label and value, in the timeseries tooltip's look; a grouped slice also lists its members. */
export default function PieTooltipContent({
id,
data,
isPinned,
canDrilldown,
onMemberClick,
onUnpin,
}: PieTooltipContentProps): JSX.Element {
const { slice, groupedMembers } = data;
const canDrillMember = isPinned && canDrilldown;
const showFooter = !!groupedMembers || canDrilldown;
return (
<div
className={cx(styles.body, {
[styles.pinned]: isPinned,
})}
>
{isPinned && (
<div className={styles.headerRow}>
<span>{groupedMembers?.length} series</span>
<TooltipPinnedBadge />
</div>
)}
<div className={styles.activeItem}>
<TooltipItem item={slice} isItemActive />
</div>
{groupedMembers && (
<>
<span className={styles.divider} />
<TooltipList
id={id}
content={groupedMembers}
onItemClick={canDrillMember ? onMemberClick : undefined}
/>
</>
)}
{showFooter && (
<PieTooltipFooter
isGroup={!!groupedMembers}
isPinned={isPinned}
canDrilldown={canDrilldown}
onUnpin={onUnpin}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { MousePointerClick, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Kbd } from '@signozhq/ui/kbd';
import footerStyles from 'lib/visualization/panels/components/TooltipFooter.module.scss';
interface PieTooltipFooterProps {
isGroup: boolean;
isPinned: boolean;
canDrilldown: boolean;
onUnpin: () => void;
}
/** The timeseries tooltip footer's look, with the pie's click-to-pin hints. */
export default function PieTooltipFooter({
isGroup,
isPinned,
canDrilldown,
onUnpin,
}: PieTooltipFooterProps): JSX.Element {
let clickHint = canDrilldown ? 'Click to drilldown' : null;
if (isGroup) {
clickHint = canDrilldown
? 'Click to pin, then pick a series'
: 'Click to pin the tooltip';
}
if (isPinned) {
clickHint = canDrilldown ? 'Click a series to drilldown' : null;
}
return (
<div className={footerStyles.footer} data-testid="pie-tooltip-footer">
<div className={footerStyles.hintList}>
{clickHint && (
<div className={footerStyles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>{clickHint}</span>
</div>
)}
{isPinned && (
<div className={footerStyles.hint} data-active="false">
<span>Press</span>
<Kbd>Esc</Kbd>
<span>to unpin</span>
</div>
)}
</div>
{isPinned && (
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onUnpin}
aria-label="Unpin tooltip"
testId="pie-tooltip-unpin"
prefix={<X size={10} />}
>
<span>Unpin</span>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,205 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type MouseEvent as ReactMouseEvent,
type RefObject,
} from 'react';
import { useTooltip } from '@visx/tooltip';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
import { getSliceDisplayLabel } from 'lib/visualization/charts/Pie/utils/slices';
// Keeps the cursor off the tooltip so it never steals the hover.
const TOOLTIP_CURSOR_OFFSET = 12;
interface UsePieTooltipArgs {
/** The drawn slices, Other included. */
slices: PieSlice[];
/** The chart's root: a click on its own arcs resolves the pin, one anywhere else unpins. */
chartRef: RefObject<HTMLElement>;
formatValue: (value: number) => string;
setFocusedKey: (key: string | null) => void;
onSliceClick: PieChartProps['onSliceClick'];
}
interface UsePieTooltipResult {
tooltipOpen: boolean;
tooltipTop?: number;
tooltipLeft?: number;
tooltipData?: PieTooltipData;
isPinned: boolean;
unpin: () => void;
handleSliceHover: (
slice: PieSlice,
event: ReactMouseEvent<SVGGElement>,
) => void;
handleSliceLeave: () => void;
handleSliceClick: (slice: PieSlice, event: ReactMouseEvent) => void;
handleMemberClick: (index: number, event: ReactMouseEvent) => void;
}
/**
* The Pie tooltip: follows the cursor over an arc, and pins on a grouped slice's click so its
* members can be drilled into. Esc or a click outside unpins.
*/
export function usePieTooltip({
slices,
chartRef,
formatValue,
setFocusedKey,
onSliceClick,
}: UsePieTooltipArgs): UsePieTooltipResult {
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<PieTooltipData>();
// A grouped slice's click pins its tooltip so its members can be drilled into.
const [pinnedSlice, setPinnedSlice] = useState<PieSlice | null>(null);
const unpin = useCallback((): void => {
setPinnedSlice(null);
hideTooltip();
setFocusedKey(null);
}, [hideTooltip, setFocusedKey]);
// New data replaces the pinned slice, whose members may no longer be drawn.
useEffect(() => {
if (pinnedSlice && !slices.includes(pinnedSlice)) {
unpin();
}
}, [slices, pinnedSlice, unpin]);
useEffect(() => {
if (!pinnedSlice) {
return undefined;
}
const onPointerDown = (event: PointerEvent): void => {
const target = event.target as Element | null;
// This chart's arc clicks resolve the pin themselves (see handleSliceClick).
const isOwnArc =
!!target?.closest('[data-pie-arc]') && !!chartRef.current?.contains(target);
if (isOwnArc || target?.closest('[data-pie-tooltip]')) {
return;
}
unpin();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
unpin();
}
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return (): void => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [pinnedSlice, unpin, chartRef]);
// Built once per data change, not on every mouse move.
const tooltipDataBySlice = useMemo(
() =>
new Map<PieSlice, PieTooltipData>(
slices.map((slice) => [
slice,
{
slice: {
label: getSliceDisplayLabel(slice),
value: slice.value,
tooltipValue: formatValue(slice.value),
color: slice.color,
isActive: true,
},
groupedMembers: slice.groupedMembers?.map((member) => ({
label: member.label,
value: member.value,
tooltipValue: formatValue(member.value),
color: member.color,
isActive: false,
})),
},
]),
),
[slices, formatValue],
);
const handleSliceHover = useCallback(
(slice: PieSlice, event: ReactMouseEvent<SVGGElement>): void => {
if (pinnedSlice) {
return;
}
const svgRect = event.currentTarget.ownerSVGElement?.getBoundingClientRect();
showTooltip({
tooltipData: tooltipDataBySlice.get(slice),
tooltipTop: event.clientY - (svgRect?.top ?? 0) + TOOLTIP_CURSOR_OFFSET,
tooltipLeft: event.clientX - (svgRect?.left ?? 0) + TOOLTIP_CURSOR_OFFSET,
});
setFocusedKey(slice.label);
},
[pinnedSlice, showTooltip, setFocusedKey, tooltipDataBySlice],
);
const handleSliceLeave = useCallback((): void => {
if (pinnedSlice) {
return;
}
hideTooltip();
setFocusedKey(null);
}, [pinnedSlice, hideTooltip, setFocusedKey]);
const handleSliceClick = useCallback(
(slice: PieSlice, event: ReactMouseEvent): void => {
if (slice.groupedMembers) {
if (pinnedSlice === slice) {
unpin();
} else {
setPinnedSlice(slice);
}
return;
}
if (pinnedSlice) {
unpin();
}
onSliceClick?.(slice, { x: event.clientX, y: event.clientY });
},
[pinnedSlice, unpin, onSliceClick],
);
const handleMemberClick = useCallback(
(index: number, event: ReactMouseEvent): void => {
const member = pinnedSlice?.groupedMembers?.[index];
const tooltipRect = (event.currentTarget as Element)
.closest('[data-pie-tooltip]')
?.getBoundingClientRect();
unpin();
if (member) {
onSliceClick?.(member, {
x: tooltipRect?.left ?? event.clientX,
y: tooltipRect?.top ?? event.clientY,
});
}
},
[pinnedSlice, unpin, onSliceClick],
);
return {
tooltipOpen,
tooltipTop,
tooltipLeft,
tooltipData,
isPinned: !!pinnedSlice,
unpin,
handleSliceHover,
handleSliceLeave,
handleSliceClick,
handleMemberClick,
};
}

View File

@@ -4,6 +4,8 @@
* (PieSlice, PieChartProps) live in the parent charts/types.ts.
*/
import type { TooltipContentItem } from 'lib/uPlotV2/components/types';
export interface ScaledFontSizeArgs {
text: string;
baseSize: number;
@@ -39,7 +41,7 @@ export interface ParsedRgb {
/** Resolved tooltip payload shown when a slice is hovered. */
export interface PieTooltipData {
label: string;
value: string;
color: string;
slice: TooltipContentItem;
/** Grouped slice only: every member, largest first. */
groupedMembers?: TooltipContentItem[];
}

View File

@@ -0,0 +1,35 @@
import {
getFillColor,
lightenColor,
} from 'lib/visualization/charts/Pie/utils/colors';
describe('Pie colors', () => {
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -1,12 +1,10 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from 'lib/visualization/charts/Pie/utils';
} from 'lib/visualization/charts/Pie/utils/geometry';
describe('Pie utils', () => {
describe('Pie geometry', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
@@ -98,33 +96,4 @@ describe('Pie utils', () => {
expect(g.textAnchor).toBe('end');
});
});
describe('lightenColor', () => {
it('converts a #rrggbb hex to rgba at the given opacity', () => {
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
});
it('accepts hex without a leading #', () => {
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
});
it('returns the original colour when it is not parseable hex', () => {
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
expect(lightenColor('red', 0.4)).toBe('red');
});
});
describe('getFillColor', () => {
it('returns the colour unchanged when nothing is active', () => {
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
});
it('returns the colour unchanged for the active slice', () => {
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
});
it('dims non-active slices to 40% opacity', () => {
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
});
});
});

View File

@@ -0,0 +1,129 @@
import type { PieSlice } from 'lib/visualization/charts/types';
import {
groupPieSlices,
OTHER_SLICE_LABEL,
} from 'lib/visualization/charts/Pie/utils/groupPieSlices';
const slice = (label: string, value: number): PieSlice => ({
label,
value,
color: '#000000',
queryName: 'A',
});
const group = (slices: PieSlice[]): PieSlice[] => groupPieSlices(slices, true);
describe('groupPieSlices', () => {
it('folds a long tail into a trailing Other, keeping kept-slice order', () => {
const slices = [
slice('b', 400),
slice('tail1', 2),
slice('a', 500),
slice('tail2', 4),
slice('tail3', 1),
];
const result = group(slices);
expect(result.map((s) => [s.label, s.value])).toStrictEqual([
['b', 400],
['a', 500],
[OTHER_SLICE_LABEL, 7],
]);
expect(result[2].groupedMembers?.map((s) => s.label)).toStrictEqual([
'tail2',
'tail1',
'tail3',
]);
expect(result[2].queryName).toBeUndefined();
});
it('caps a flat pie at 14 slices + Other', () => {
const slices = Array.from({ length: 30 }, (_, i) => slice(`s${i}`, 100 - i));
const result = group(slices);
expect(result).toHaveLength(15);
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
slices.slice(0, 14).map((s) => s.label),
);
expect(result[14].label).toBe(OTHER_SLICE_LABEL);
expect(result[14].groupedMembers).toHaveLength(16);
});
it('keeps exactly 15 large slices ungrouped', () => {
const slices = Array.from({ length: 15 }, (_, i) => slice(`s${i}`, 10));
expect(group(slices)).toBe(slices);
});
it('leaves a single small slice under its own name', () => {
const slices = [slice('a', 500), slice('b', 400), slice('tiny', 1)];
expect(group(slices)).toBe(slices);
});
it('keeps a dominant Other at its true size and conserves the total', () => {
const slices = [
slice('a', 300),
...Array.from({ length: 400 }, (_, i) => slice(`t${i}`, 1)),
];
const result = group(slices);
expect(result.map((s) => s.label)).toStrictEqual(['a', OTHER_SLICE_LABEL]);
expect(result[1].value).toBe(400);
expect(result[1].groupedMembers).toHaveLength(400);
});
it('keeps tiny slices grouped past 15 series', () => {
const slices = [
slice('a', 3000),
slice('b', 2000),
slice('c', 1000),
...Array.from({ length: 13 }, (_, i) => slice(`t${i}`, 1)),
];
const result = group(slices);
expect(result.map((s) => s.label)).toStrictEqual([
'a',
'b',
'c',
OTHER_SLICE_LABEL,
]);
expect(result[3].groupedMembers).toHaveLength(13);
});
it('keeps 14 slices when every slice is under the share threshold', () => {
// 200 × 0.5%: grouping by share alone would leave one 100% Other.
const slices = Array.from({ length: 200 }, (_, i) => slice(`s${i}`, 5));
const result = group(slices);
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
slices.slice(0, 14).map((s) => s.label),
);
expect(result[14].label).toBe(OTHER_SLICE_LABEL);
expect(result[14].groupedMembers).toHaveLength(186);
expect(result[14].value).toBe(930);
});
it('breaks ties by input order', () => {
const slices = Array.from({ length: 20 }, (_, i) => slice(`s${i}`, 5));
const result = group(slices);
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
slices.slice(0, 14).map((s) => s.label),
);
expect(result[14].groupedMembers?.map((s) => s.label)).toStrictEqual(
slices.slice(14).map((s) => s.label),
);
});
it('returns an empty pie unchanged', () => {
expect(group([])).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,26 @@
import type { PieSlice } from 'lib/visualization/charts/types';
import { compareSlices } from 'lib/visualization/charts/Pie/utils/slices';
describe('Pie slices', () => {
describe('compareSlices', () => {
it('orders largest first with the grouped slice last', () => {
const slice = (label: string, value: number, grouped = false): PieSlice => ({
label,
value,
color: '#000000',
...(grouped ? { groupedMembers: [] } : {}),
});
const slices = [
slice('small', 1),
slice('Other', 500, true),
slice('big', 100),
];
expect([...slices].sort(compareSlices).map((s) => s.label)).toStrictEqual([
'big',
'small',
'Other',
]);
});
});
});

View File

@@ -0,0 +1,41 @@
import { ParsedRgb } from 'lib/visualization/charts/Pie/types';
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Returns an rgba() string for `color` at the given opacity. Used to dim the
* non-hovered slices. Falls back to the original colour if it can't be parsed.
*/
export function lightenColor(color: string, opacity: number): string {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}
/**
* Resolves the fill for a slice given the currently-hovered slice colour:
* everything but the active slice dims to 40% opacity. With nothing hovered
* (`activeColor === null`) every slice keeps its full colour.
*/
export function getFillColor(
color: string,
activeColor: string | null,
): string {
if (activeColor === null) {
return color;
}
return activeColor === color ? color : lightenColor(color, 0.4);
}

View File

@@ -1,12 +1,8 @@
/**
* Pure presentation helpers for the Pie chart. Kept out of the component file
* so the renderer stays declarative (per the one-component-per-file rule).
*/
/** Donut, label and font sizing for the Pie chart. */
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from 'lib/visualization/charts/Pie/types';
@@ -77,43 +73,3 @@ export function getArcGeometry(
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
};
}
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
// already-rgba string), letting callers fall back to the original colour.
function hexToRgb(color: string): ParsedRgb | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Returns an rgba() string for `color` at the given opacity. Used to dim the
* non-hovered slices. Falls back to the original colour if it can't be parsed.
*/
export function lightenColor(color: string, opacity: number): string {
const rgb = hexToRgb(color);
if (!rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}
/**
* Resolves the fill for a slice given the currently-hovered slice colour:
* everything but the active slice dims to 40% opacity. With nothing hovered
* (`activeColor === null`) every slice keeps its full colour.
*/
export function getFillColor(
color: string,
activeColor: string | null,
): string {
if (activeColor === null) {
return color;
}
return activeColor === color ? color : lightenColor(color, 0.4);
}

View File

@@ -0,0 +1,40 @@
import { Color } from '@signozhq/design-tokens';
import type { PieSlice } from 'lib/visualization/charts/types';
export const OTHER_SLICE_LABEL = 'Other';
const MIN_SHARE = 0.01;
const MAX_SLICES = 15;
const MIN_OTHER_MEMBERS = 2;
/**
* Folds slices at or under `MIN_SHARE`, plus any past `MAX_SLICES` (Other included), into one
* trailing "Other" slice. When no slice clears `MIN_SHARE`, the largest `MAX_SLICES - 1` stay so
* the pie never collapses into a single Other. Kept slices keep their order. No-op when fewer
* than two would fold.
*/
export function groupPieSlices(
slices: PieSlice[],
isDarkMode: boolean,
): PieSlice[] {
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
const sorted = [...slices].sort((a, b) => b.value - a.value);
const aboveShare = sorted.filter(
(slice) => slice.value / total > MIN_SHARE,
).length;
const keptCount = Math.min(aboveShare || MAX_SLICES - 1, MAX_SLICES - 1);
const groupedMembers = sorted.slice(keptCount);
if (groupedMembers.length < MIN_OTHER_MEMBERS) {
return slices;
}
const kept = new Set(sorted.slice(0, keptCount));
const other: PieSlice = {
label: OTHER_SLICE_LABEL,
value: groupedMembers.reduce((sum, slice) => sum + slice.value, 0),
color: isDarkMode ? Color.BG_SLATE_50 : Color.BG_VANILLA_400,
groupedMembers,
};
return [...slices.filter((slice) => kept.has(slice)), other];
}

View File

@@ -0,0 +1,15 @@
import type { PieSlice } from 'lib/visualization/charts/types';
/** Grouped slices read "Other (n)"; `label` stays the key for colours and visibility. */
export function getSliceDisplayLabel(slice: PieSlice): string {
return slice.groupedMembers
? `${slice.label} (${slice.groupedMembers.length})`
: slice.label;
}
/** Arc order: largest first, with a grouped slice always last so it closes the circle. */
export function compareSlices(a: PieSlice, b: PieSlice): number {
return (
Number(!!a.groupedMembers) - Number(!!b.groupedMembers) || b.value - a.value
);
}

View File

@@ -1,5 +1,4 @@
import uPlot from 'uplot';
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -180,6 +179,8 @@ export interface PieSlice {
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
/** Slices folded into this one, largest first. Present only on the "Other" slice. */
groupedMembers?: PieSlice[];
}
/**
@@ -200,8 +201,11 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
/**
* Fired when a slice is clicked, with the viewport point to anchor the drill-down popover:
* the cursor for an arc, the pinned tooltip's corner for a grouped slice's member.
*/
onSliceClick?: (slice: PieSlice, anchor: { x: number; y: number }) => void;
'data-testid'?: string;
}

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