Compare commits

..

14 Commits

Author SHA1 Message Date
Srikanth Chekuri
38f4b85a78 Merge branch 'main' into fix/query-range-validation-gaps 2026-11-05 21:04:47 +05:30
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
srikanthccv
7268d5ebf5 test(querier): name the new validation cases in the PascalCase form
Assisted-by: Claude Opus 5.5
2026-11-05 15:47:33 +05:30
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
srikanthccv
9fc012d819 chore: generate openapi spec
Assisted-by: Claude Opus 5.5
2026-11-05 02:40:45 +05:30
srikanthccv
e17252420c fix(querier): keep distribution in the request type description
Assisted-by: Claude Opus 5.5
2026-11-05 02:40:44 +05:30
srikanthccv
ac153c34f3 chore: generate openapi spec
Assisted-by: Claude Opus 5.5
2026-11-05 02:29:38 +05:30
srikanthccv
f234da5983 fix(querier): reject negative formula limits and empty raw order keys, and name the valid request types
distribution still decodes, because saved views and dashboards can store it.
Validate rejects it and lists the request types a query can use. Rules and
saved views validate on write only, so stored ones still load.

Assisted-by: Claude Opus 5.5
2026-11-05 02:29:38 +05:30
113 changed files with 3168 additions and 1122 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

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

@@ -46,5 +46,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

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

@@ -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,12 @@
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
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.
@@ -43,14 +46,13 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
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 +112,41 @@ 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();
});
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,9 @@ 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));
return [openTraceColumn, ...fieldColumns];
}, [options?.selectColumns, openTraceColumn]);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
@@ -230,10 +174,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 +193,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 +212,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,52 @@
import { MouseEvent } from 'react';
import { CornerUpRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { useIsRowHovered } from 'components/TanStackTableView/TanStackTableStateContext';
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();
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

@@ -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,5 +1,6 @@
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';
@@ -15,6 +16,8 @@ import {
} from './constants';
import { stringifyCellValue } from './utils';
import styles from './FieldCell.module.scss';
type FieldCellProps = {
name: string;
value: unknown;
@@ -37,7 +40,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);

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,4 +1,4 @@
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';
@@ -6,14 +6,20 @@ 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 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 +42,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 +65,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;
@@ -110,8 +143,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 +172,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

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

@@ -4,9 +4,6 @@ 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' },

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

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

View File

@@ -110,7 +110,6 @@
&.active {
background: var(--l1-background-hover);
color: var(--l1-foreground);
font-weight: var(--font-weight-medium, 500);
}
&:focus-visible {

View File

@@ -1,8 +1,4 @@
import {
useCallback,
useMemo,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'lib/visualization/charts/Pie/Pie';
import type { PieSlice } from 'lib/visualization/charts/types';
@@ -75,14 +71,14 @@ function PiePanelRenderer({
);
const handleSliceClick = useCallback(
(slice: PieSlice, event: ReactMouseEvent): void => {
(slice: PieSlice, coordinates: { x: number; y: number }): void => {
if (!onClick) {
return;
}
const payload = enrichPieClick({
slice,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {

View File

@@ -38,6 +38,7 @@ import {
PANEL_KINDS,
PANEL_RESULTS,
PANELS_DASHBOARD_ID,
PIE_SLICES,
SPAN_GAPS,
STACK_MODES,
TEXT_ALIGNS,
@@ -63,6 +64,7 @@ import type {
PanelKind,
PanelResult,
PanelUnit,
PieSlices,
SpanGaps,
TextSample,
YScale,
@@ -199,6 +201,13 @@ export const panelsMocks = defineStoryMocks({
value: 4,
max: MAX_SERIES,
}),
pieSlices: choiceControl<PieSlices>('Pie slices', {
group: DATA,
description:
'`even` steps down per service (Series sets how many); `long-tail` answers 24 services, the smallest 10 folding into Other.',
options: PIE_SLICES,
value: 'even',
}),
gaps: choiceControl<Gaps>('Gaps', {
group: DATA,
description:
@@ -267,6 +276,7 @@ export const panelsMocks = defineStoryMocks({
listRows: values.listRows,
listJsonBody: values.listJsonBody,
gaps: values.gaps,
pieSlices: values.pieSlices,
});
}),
),

View File

@@ -1,6 +1,5 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import {
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
DashboardtypesLegendPositionDTO as LegendPosition,
@@ -16,9 +15,13 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import DashboardPage from '../../DashboardPage';
import { panelsMocks } from './Panels.stories.mocks';
import { expectPanel } from './panelPlay/expectPanel';
import { filterLegend, searchTable } from './panelPlay/interactions';
import { walkListPages } from './panelPlay/list';
import {
playLegendRight,
playListJsonBody,
playListPaginated,
playPanel,
playTableSearch,
} from './panelPlay/plays';
type PanelsArgs = PageStoryArgs<typeof panelsMocks>;
@@ -49,53 +52,49 @@ export default meta;
type Story = StoryObj<PanelsArgs>;
const play: Story['play'] = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
};
/** A time series panel: p99 latency, one line per service, with a bottom legend. */
export const Default: Story = { play };
export const Default: Story = { play: playPanel };
/** One bar series per service over time, side by side. */
export const Bar: Story = {
args: { kind: 'Bar' },
play,
play: playPanel,
};
/** The services' bars stacked on one another at every moment. */
export const BarStacked: Story = {
args: { kind: 'Bar', stack: StackMode.normal },
play,
play: playPanel,
};
/** The services as filled areas, drawn independently. */
export const Area: Story = {
args: { kind: 'Area' },
play,
play: playPanel,
};
/** The services' areas stacked on one another, each band filling to the one below. */
export const AreaStacked: Story = {
args: { kind: 'Area', stack: StackMode.normal },
play,
play: playPanel,
};
/** The stacked areas rescaled so every moment sums to 100%, each band its service's share. */
export const AreaPercent: Story = {
args: { kind: 'Area', stack: StackMode.percent },
play,
play: playPanel,
};
/** How the latency values distribute across buckets, one bar set per service. */
export const Histogram: Story = {
args: { kind: 'Histogram' },
play,
play: playPanel,
};
/** The histogram binned 50 apart, every bin starting on a multiple of 50. */
export const HistogramBucketWidth: Story = {
args: { kind: 'Histogram', bucketWidth: '50' },
play,
play: playPanel,
};
/**
@@ -104,25 +103,34 @@ export const HistogramBucketWidth: Story = {
*/
export const Heatmap: Story = {
args: { kind: 'Heatmap' },
play,
play: playPanel,
};
/** The heatmap's bucket rows laid out on a log scale. */
export const HeatmapLog: Story = {
args: { kind: 'Heatmap', heatmapScale: HeatmapYScale.log },
play,
play: playPanel,
};
/** The heatmap's colour bar pinned to a fixed 5–250 count range. */
export const HeatmapCountRange: Story = {
args: { kind: 'Heatmap', heatmapCounts: 'fixed' },
play,
play: playPanel,
};
/** Each service's share of traffic as a donut, with the total in the middle. */
export const Pie: Story = {
args: { kind: 'Pie' },
play,
play: playPanel,
};
/**
* 24 services of falling traffic: the smallest 10 fold into one grey "Other (10)" slice,
* which lists them on hover; click pins the list to drill into any of them.
*/
export const PieOther: Story = {
args: { kind: 'Pie', pieSlices: 'long-tail' },
play: playPanel,
};
/**
@@ -131,13 +139,13 @@ export const Pie: Story = {
*/
export const Number: Story = {
args: { kind: 'Number', thresholds: true },
play,
play: playPanel,
};
/** The value's query matched nothing, which the Number panel draws as its own no-data state. */
export const NumberNoData: Story = {
args: { kind: 'Number', result: 'empty' },
play,
play: playPanel,
};
/**
@@ -146,7 +154,7 @@ export const NumberNoData: Story = {
*/
export const Table: Story = {
args: { kind: 'Table', thresholds: true, columnUnits: true },
play,
play: playPanel,
};
/**
@@ -155,16 +163,13 @@ export const Table: Story = {
*/
export const TableSearch: Story = {
args: { kind: 'Table' },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
await searchTable(canvasElement, args);
},
play: playTableSearch,
};
/** The latest log lines, newest first, on a single page. */
export const List: Story = {
args: { kind: 'List' },
play,
play: playPanel,
};
/**
@@ -173,16 +178,7 @@ export const List: Story = {
*/
export const ListJsonBody: Story = {
args: { kind: 'List', listJsonBody: true },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
const [first, , third] = within(canvasElement)
.getByTestId('list-panel-renderer')
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
await expect(
within(third).getByText(/^\{"message":"log line 3:/),
).toBeInTheDocument();
},
play: playListJsonBody,
};
/**
@@ -191,28 +187,25 @@ export const ListJsonBody: Story = {
*/
export const ListPaginated: Story = {
args: { kind: 'List', listRows: 60 },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
await walkListPages(canvasElement, args);
},
play: playListPaginated,
};
/** A Markdown runbook: heading, list, link and code block. */
export const Text: Story = {
args: { kind: 'Text' },
play,
play: playPanel,
};
/** The time series on a logarithmic y axis. */
export const LogScale: Story = {
args: { yScale: 'log' },
play,
play: playPanel,
};
/** Dashed lines with a marker on every sample. */
export const LineStyle: Story = {
args: { lineStyle: LineStyleDTO.dashed, showPoints: true },
play,
play: playPanel,
};
/**
@@ -221,7 +214,7 @@ export const LineStyle: Story = {
*/
export const SpanGaps: Story = {
args: { gaps: 'hole', spanGaps: 'break-10m' },
play,
play: playPanel,
};
/**
@@ -230,26 +223,23 @@ export const SpanGaps: Story = {
*/
export const LegendRight: Story = {
args: { legend: LegendPosition.right },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
await filterLegend(canvasElement, args);
},
play: playLegendRight,
};
/** The dashed SLO line across the time series, with its label. */
export const Thresholds: Story = {
args: { thresholds: true },
play,
play: playPanel,
};
/** The query ran and matched nothing, which the panel draws as its no-data state. */
export const NoData: Story = {
args: { result: 'empty' },
play,
play: playPanel,
};
/** Data that came back with a warning, flagged in the panel header. */
export const Warning: Story = {
args: { result: 'warning' },
play,
play: playPanel,
};

View File

@@ -67,6 +67,11 @@ export const LINE_STYLES = Object.values(LineStyle);
export const GAPS = ['none', 'hole'] as const;
export const PIE_SLICES = ['even', 'long-tail'] as const;
/** Slices `long-tail` answers with: 14 drawn, the other 10 grouped (the pie draws at most 15). */
export const LONG_TAIL_SLICES = 24;
export const SPAN_GAPS = ['bridge', 'break-10m'] as const;
/** Points cut out of every series by `hole`: 12 of the 30, a gap past the 10m break. */

View File

@@ -26,13 +26,14 @@ import {
HEATMAP_BUCKETS,
HEATMAP_COLUMNS,
HOLE,
LONG_TAIL_SLICES,
MAX_SERIES,
NUMBER_VALUE,
QUERY_NAME,
SERVICES,
TABLE_SECOND_COLUMN,
} from './constants';
import type { Gaps, PanelRequest, PanelResponseArgs } from './types';
import type { Gaps, PanelRequest, PanelResponseArgs, PieSlices } from './types';
export const serviceAt = (index: number): string =>
index < SERVICES.length
@@ -42,6 +43,14 @@ export const serviceAt = (index: number): string =>
export const TABLE_VALUES = (count: number): number[] =>
Array.from({ length: count }, (_unused, index) => 3800 - index * 480);
/** The pie's slice values: `even` steps down per series, `long-tail` falls off like a power law. */
export const PIE_VALUES = (slices: PieSlices, count: number): number[] =>
slices === 'even'
? TABLE_VALUES(count)
: Array.from({ length: LONG_TAIL_SLICES }, (_unused, index) =>
Math.round(42000 / (index + 1) ** 1.35),
);
const TABLE_SECOND_VALUES = (count: number): number[] =>
Array.from({ length: count }, (_unused, index) => 2000 + index * 600);
@@ -192,6 +201,7 @@ export const panelQueryResponse = ({
listRows,
listJsonBody,
gaps,
pieSlices,
window,
offset,
limit,
@@ -209,7 +219,10 @@ export const panelQueryResponse = ({
return queryRangeV5ScalarTableResponse({
groupBy: ['service.name'],
aggregations: [QUERY_NAME],
rows: TABLE_VALUES(series).map((value, index) => [serviceAt(index), value]),
rows: PIE_VALUES(pieSlices, series).map((value, index) => [
serviceAt(index),
value,
]),
warning,
});
case 'Table': {

View File

@@ -20,6 +20,7 @@ import type {
HEATMAP_COUNT_RANGES,
PANEL_KINDS,
PANEL_RESULTS,
PIE_SLICES,
SPAN_GAPS,
TEXT_SAMPLES,
UNITS,
@@ -44,6 +45,8 @@ export type Gaps = (typeof GAPS)[number];
export type SpanGaps = (typeof SPAN_GAPS)[number];
export type PieSlices = (typeof PIE_SLICES)[number];
/** The controls that shape the dashboard document. */
export interface PanelArgs {
kind: PanelKind;
@@ -79,4 +82,5 @@ export interface PanelResponseArgs extends PanelRequest {
listRows: number;
listJsonBody: boolean;
gaps: Gaps;
pieSlices: PieSlices;
}

View File

@@ -0,0 +1,42 @@
import { expect, within } from 'storybook/test';
import type { PanelPlayArgs } from './args';
import { expectPanel } from './expectPanel';
import { filterLegend, searchTable } from './interactions';
import { walkListPages } from './list';
type PanelPlay = (context: {
canvasElement: HTMLElement;
args: PanelPlayArgs;
}) => Promise<void>;
/** Asserts what the panel kind draws. */
export const playPanel: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
};
export const playTableSearch: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
await searchTable(canvasElement, args);
};
export const playListJsonBody: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
const [first, , third] = within(canvasElement)
.getByTestId('list-panel-renderer')
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
await expect(
within(third).getByText(/^\{"message":"log line 3:/),
).toBeInTheDocument();
};
export const playListPaginated: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
await walkListPages(canvasElement, args);
};
export const playLegendRight: PanelPlay = async ({ canvasElement, args }) => {
await expectPanel(canvasElement, args);
await filterLegend(canvasElement, args);
};

View File

@@ -6,12 +6,16 @@ import {
TABLE_THRESHOLD_VALUE,
} from '../__story_mockdata__/constants';
import {
PIE_VALUES,
serviceIndex,
TABLE_VALUES,
} from '../__story_mockdata__/panelResponses';
import { PANEL_LOAD, type PanelPlayArgs } from './args';
// `long-tail` draws 14 slices and folds the other 10 into Other.
const LONG_TAIL_KEPT = 14;
export const expectPie = async (
root: HTMLElement,
args: PanelPlayArgs,
@@ -19,9 +23,18 @@ export const expectPie = async (
const chart = await within(root).findByTestId('pie-chart', {}, PANEL_LOAD);
// The legend's icons are SVGs too; the donut is the first one.
const donut = chart.querySelector('svg');
await expect(donut?.querySelectorAll('g > path')).toHaveLength(args.series);
const values = PIE_VALUES(args.pieSlices, args.series);
const isGrouped = args.pieSlices === 'long-tail';
await expect(donut?.querySelectorAll('g > path')).toHaveLength(
isGrouped ? LONG_TAIL_KEPT + 1 : values.length,
);
if (isGrouped) {
await expect(
within(chart).getByText(`Other (${values.length - LONG_TAIL_KEPT})`),
).toBeInTheDocument();
}
// The centre shows the slices' sum, formatted as the panel formats values.
const total = TABLE_VALUES(args.series).reduce((sum, value) => sum + value, 0);
const total = values.reduce((sum, value) => sum + value, 0);
await expect(
donut?.querySelector('text[text-anchor="middle"]')?.textContent,
).toBe(getYAxisFormattedValue(String(total), args.unit).replace(/\s+/g, ''));

View File

@@ -0,0 +1,13 @@
.views {
padding: 8px 0;
margin-bottom: 24px;
}
.tableView {
flex: 1;
}
.insetView {
flex: 1;
padding-inline: 8px;
}

View File

@@ -21,15 +21,6 @@
justify-content: flex-end;
}
.traces-explorer-views {
padding: 8px;
margin-bottom: 24px;
.ant-tabs-tabpane {
padding: 0 8px;
}
}
.qb-search-view-container {
padding: 8px;
@@ -47,22 +38,6 @@
}
}
.trace-explorer-list-view {
flex: 1;
}
.trace-explorer-traces-view {
flex: 1;
}
.trace-explorer-table-view {
flex: 1;
}
.trace-explorer-time-series-view {
flex: 1;
}
.trace-explorer-page {
// Meant to fix the query builder colors
--input-background: var(--l2-background);

View File

@@ -168,16 +168,17 @@ describe('TracesExplorer -', () => {
),
);
const { getByText } = renderWithTracesExplorerRouter(<TracesExplorer />);
renderWithTracesExplorerRouter(<TracesExplorer />);
await screen.findByText(FILTER_SERVICE_NAME);
await screen.findByText('demo-app');
expect(getByText('options_menu.options')).toBeInTheDocument();
expect(
screen.getByTestId('periscope-btn-options-traces'),
).toBeInTheDocument();
// test if pagination is there
expect(getByText('Previous')).toBeInTheDocument();
expect(getByText('Next')).toBeInTheDocument();
// the list scrolls instead of paging
expect(screen.queryByTestId('pagination-page-size')).not.toBeInTheDocument();
// column interaction is covered in E2E tests as its a complex interaction
});

View File

@@ -55,6 +55,8 @@ import {
} from './aiActions';
import TimeSeriesView from './TimeSeriesView';
import styles from './TracesExplorer.module.scss';
import './TracesExplorer.styles.scss';
function TracesExplorer(): JSX.Element {
@@ -272,13 +274,13 @@ function TracesExplorer(): JSX.Element {
<QuerySection />
</div>
<div className="traces-explorer-views">
<div className={styles.views}>
{isCancelled && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
)}
{!isCancelled && selectedView === ExplorerViews.LIST && (
<div className="trace-explorer-list-view">
<div className={styles.tableView}>
<ListView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
@@ -290,7 +292,7 @@ function TracesExplorer(): JSX.Element {
)}
{!isCancelled && selectedView === ExplorerViews.TRACE && (
<div className="trace-explorer-traces-view">
<div className={styles.tableView}>
<TracesView
isFilterApplied={isFilterApplied}
setWarning={setWarning}
@@ -302,7 +304,7 @@ function TracesExplorer(): JSX.Element {
)}
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
<div className="trace-explorer-time-series-view">
<div className={styles.insetView}>
<TimeSeriesView
dataSource={DataSource.TRACES}
isFilterApplied={isFilterApplied}
@@ -315,7 +317,7 @@ function TracesExplorer(): JSX.Element {
)}
{!isCancelled && selectedView === ExplorerViews.TABLE && (
<div className="trace-explorer-table-view">
<div className={styles.insetView}>
<TableView
setWarning={setWarning}
setIsLoadingQueries={setIsLoadingQueries}

View File

@@ -4,9 +4,6 @@
*/
import { rest } from 'msw';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
@@ -109,9 +106,9 @@ export const tracesMocks = defineStoryMocks({
spans: countControl('Spans', {
group: LIST,
description:
'Spans the endpoint has, one root span per trace in the Trace view. The table asks for a page at a time, so a higher count paginates.',
'Spans the endpoint has, one root span per trace in the Trace view. The list loads 100 more as it scrolls to the end; the Trace view pages 20 at a time.',
value: 24,
max: 40,
max: 250,
}),
errors: toggleControl('Failing spans', {
group: LIST,
@@ -119,12 +116,6 @@ export const tracesMocks = defineStoryMocks({
'Answers 503 on the spans of the services that fail, which is what the status column and the error colouring read.',
value: true,
}),
spanDrawer: toggleControl('Span drawer', {
group: LIST,
description:
'The `TRACES_SPAN_DRAWER_ENABLED` localStorage flag the span drawer ships behind. Off, a row click opens the trace page.',
value: false,
}),
traceSummary: choiceControl<TraceSummaryState>('Trace summary', {
group: LIST,
description:
@@ -362,11 +353,4 @@ export const tracesMocks = defineStoryMocks({
),
],
config: (values) => ({ route: tracesRoute(values) }),
effect: (values) => {
if (values.spanDrawer) {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
} else {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
}
},
});

View File

@@ -6,7 +6,7 @@ import {
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -58,19 +58,56 @@ const openQuickFiltersSettings = async (): Promise<void> => {
/**
* The Explorer tab on a full window of spans: the org's quick filters down the
* left, the query builder and the view switcher above, and the list paging ten
* spans at a time with the failing ones answering 503.
* left, the query builder and the view switcher above, and the span list with
* the failing ones answering 503.
*/
export const Default: Story = {};
/**
* The Trace view, which shows one row per trace: its root span, the root
* duration and how many spans the trace has.
* duration and how many spans the trace has, twenty traces to a page.
*/
export const RootSpans: Story = {
args: { view: ExplorerViews.TRACE },
};
/**
* The Trace view on its second page. The API sends no total, so the pager
* offers one more page after each full one.
*/
export const RootSpansNextPage: Story = {
args: { view: ExplorerViews.TRACE, spans: 50 },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('button', { name: 'Go to next page' }, untilLoaded),
);
const pager = screen.getByLabelText('pagination');
await expect(
await within(pager).findByText('3', undefined, untilLoaded),
).toBeVisible();
},
};
/** The span list scrolled to its end, with the next 100 spans loaded under the first. */
export const ListNextPage: Story = {
args: { spans: 150 },
play: async ({ canvasElement }): Promise<void> => {
// Scrolls again on every attempt, since each loaded page moves the end.
await waitFor(async (): Promise<void> => {
const scroller = canvasElement.querySelector<HTMLElement>(
'[data-virtuoso-scroller]',
);
await expect(scroller).not.toBeNull();
(scroller as HTMLElement).scrollTop = (scroller as HTMLElement).scrollHeight;
await expect(
canvasElement.querySelector('tr[data-index="120"]'),
).not.toBeNull();
}, untilLoaded);
},
};
/** The charted view of the same trace query. */
export const Timeseries: Story = {
args: { view: ExplorerViews.TIMESERIES },

View File

@@ -25,7 +25,6 @@ const meta = {
component: TracesModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
args: { spanDrawer: true },
} satisfies Meta<TracesArgs>;
export default meta;
@@ -37,8 +36,8 @@ const untilLoaded = { timeout: 15_000 };
// The quick filters list the same span name, so the row is found by its test
// id; the table renders its rows a few at a time, so it waits for this one.
const openDrawer = async (): Promise<HTMLElement> => {
const row = await waitFor((): HTMLElement => {
const findOrdersRow = (): Promise<HTMLElement> =>
waitFor((): HTMLElement => {
const match = screen
.getAllByTestId(/^traces-table-row-/)
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
@@ -48,6 +47,9 @@ const openDrawer = async (): Promise<HTMLElement> => {
return match as HTMLElement;
}, untilLoaded);
const openDrawer = async (): Promise<HTMLElement> => {
const row = await findOrdersRow();
await userEvent.click(within(row).getByText('POST /api/orders'));
return screen.findByTestId('span-details-drawer');
@@ -80,6 +82,19 @@ export const Default: Story = {
},
};
/**
* A hovered row with the icon at its start that opens the trace page in a new
* tab, without opening the drawer.
*/
export const OpenTraceIcon: Story = {
play: async ({ mount }): Promise<void> => {
await mount();
await userEvent.hover(await findOrdersRow());
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
},
};
/** The Logs tab with the span's logs, between the logs just before and after them. */
export const Logs: Story = {
args: { spanLogs: 'span' },

View File

@@ -9,12 +9,13 @@ import (
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
// "valid <noun> are ..." list consistently across the codebase.
const (
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounSignals = "signals"
NounReferences = "references"
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounRequestTypes = "request types"
NounSignals = "signals"
NounReferences = "references"
)
const (

View File

@@ -22,6 +22,10 @@ var (
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
ErrCodeNoCommonType = errors.MustNewCode("no_common_type")
ErrCodeNotAnAggregate = errors.MustNewCode("not_an_aggregate")
ErrCodeIllegalAggregation = errors.MustNewCode("illegal_aggregation")
ErrCodeInvalidRegexp = errors.MustNewCode("invalid_regexp")
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
@@ -87,6 +91,26 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
},
chproto.ErrNoCommonType: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeNoCommonType, "values have no common type: %s", ex.Message).
WithSuggestions("cast the values to one type, for example with toInt64 or toFloat64, so that the branches of if or multiIf, the elements of an array, or the columns of a UNION share it; count() returns UInt64, which has no common type with Int64")
},
chproto.ErrNotAnAggregate: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeNotAnAggregate, "column is neither in GROUP BY nor inside an aggregate function: %s", ex.Message).
WithSuggestions("add the column to GROUP BY, or wrap it in an aggregate function such as any()")
},
chproto.ErrIllegalAggregation: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalAggregation, "aggregate function in a place that does not allow one: %s", ex.Message).
WithSuggestions("give an aggregate an alias that is not a column name, because ClickHouse reads the alias in WHERE; move a condition on an aggregate from WHERE to HAVING; compute a nested aggregate in a subquery")
},
chproto.ErrSizesOfArraysDontMatch: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "arrays of different sizes: %s", ex.Message).
WithSuggestions("pass arrays of the same length to a function that takes several arrays, such as arrayMap or arrayZip")
},
chproto.ErrCannotCompileRegexp: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeInvalidRegexp, "regular expression does not compile: %s", ex.Message).
WithSuggestions("write the pattern in RE2 syntax, which has no lookaround or backreferences, and escape a backslash that is meant literally")
},
}
type provider struct {

View File

@@ -104,6 +104,10 @@ func (f QueryBuilderFormula) Validate() error {
)
}
if f.Limit < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
}
// Validate functions if present
for i, fn := range f.Functions {
if err := fn.Validate(); err != nil {

View File

@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
*r = v
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
}

View File

@@ -495,6 +495,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
return q.validateOrderByForAggregation()
}
// a raw order key resolves like a select field, so only an empty one is
// invalid before resolution
for i, order := range q.Order {
if order.Key.Name == "" {
orderID := fmt.Sprintf("order by clause #%d", i+1)
if q.Name != "" {
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
}
}
return nil
}
@@ -582,19 +594,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
return err
}
// Validate request type
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
opts = append(opts, GetValidationOptions(r.RequestType)...)
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid request type: %s",
r.RequestType,
).WithAdditional(
"Valid request types are: raw, timeseries, scalar, heatmap",
)
if err := r.validateRequestType(); err != nil {
return err
}
opts = append(opts, GetValidationOptions(r.RequestType)...)
if err := r.validateHeatmap(); err != nil {
return err
@@ -635,14 +638,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
return nil, err
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
if err := r.validateRequestType(); err != nil {
return nil, err
}
opts := GetValidationOptions(r.RequestType)
if err := r.validateHeatmap(); err != nil {
return nil, err
@@ -699,6 +698,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// Enum is the one list of request types a query can use, so the spec and the
// suggestions agree with what is accepted.
func (r *QueryRangeRequest) validateRequestType() error {
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
return nil
}
if r.RequestType == RequestTypeUnknown {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
func (r *QueryRangeRequest) validateTimeRange() error {
if r.RequestType == RequestTypeRawStream {
return nil

View File

@@ -1,12 +1,17 @@
package querybuildertypesv5
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func contains(s, substr string) bool {
@@ -521,6 +526,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
wantErr: true,
errMsg: "expression cannot be blank",
},
{
name: "Formula_NegativeLimit_Rejected",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeFormula,
Spec: QueryBuilderFormula{
Name: "F1",
Expression: "A * 2",
Limit: -5,
},
},
},
},
},
wantErr: true,
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
},
{
name: "promql with empty query should return error",
request: QueryRangeRequest{
@@ -696,6 +723,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
wantErr: true,
errMsg: "raw request type is not supported for metric queries",
},
{
name: "Raw_EmptyOrderKey_Rejected",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeRaw,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
Order: []OrderBy{
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
},
},
},
},
},
},
wantErr: true,
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
},
{
name: "timeseries request with group by timestamp should return error",
request: QueryRangeRequest{
@@ -1407,6 +1459,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
}
}
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`"}
testCases := []struct {
name string
requestType string
message string
}{
{
name: "Missing_Required",
requestType: "",
message: "requestType is required",
},
{
name: "Timeseries_Unknown",
requestType: `"requestType": "timeseries",`,
message: `unknown request type "timeseries"`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
var request QueryRangeRequest
err := json.Unmarshal([]byte(body), &request)
if err == nil {
err = request.Validate()
}
require.Error(t, err)
got := errors.AsJSON(err)
assert.Equal(t, testCase.message, got.Message)
assert.Equal(t, validRequestTypes, got.Suggestions)
})
}
}
func TestNonAggregationFieldsSkipped(t *testing.T) {
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
// should be silently skipped for non-aggregation request types.

View File

@@ -24,7 +24,18 @@
"playwright/no-conditional-in-test": "error",
"playwright/no-page-pause": "error",
"playwright/no-wait-for-timeout": "warn",
"playwright/prefer-web-first-assertions": "error"
"playwright/prefer-web-first-assertions": "error",
"no-restricted-properties": [
"error",
{
"property": "scrollIntoViewIfNeeded",
"message": "Scroll with wheelTo (drivers/scroll.ts), as a user would."
},
{
"property": "scrollIntoView",
"message": "Scroll with wheelTo (drivers/scroll.ts), as a user would."
}
]
},
"overrides": [
{

View File

@@ -128,6 +128,11 @@ never by reaching for another fixture.
- Locate by testid. A missing one is added to the product in the same change.
Role, label and CSS lookups are converted as you touch them; antd internals
(`antd.ts`) are the exception.
- Scroll with `wheelTo` (`drivers/scroll.ts`) before acting on an element that
may be off screen. It turns the mouse wheel and asserts `toBeInViewport()`.
Scripted scrolling (`scrollIntoView`, `scrollIntoViewIfNeeded`) moves even an
`overflow: hidden` box a user cannot scroll, so a clipped layout passes; lint
rejects it.
- Never `test.slow()` in a driver: it triples the remaining budget on every
call and compounds in loops. Use `allowFor(floor)`. Budgets and their
measurements are in [`docs/flake-notes.md`](docs/flake-notes.md).

View File

@@ -38,6 +38,7 @@ import {
} from './configuration';
import { alertSeeder, type AlertSeeder } from './seed';
import type { TimelineItem, TimelineResponse } from './types';
import { wheelTo } from '../scroll';
/**
* The alerts driver: one object per surface, all bound to one page.
@@ -520,6 +521,7 @@ export function alertsDriver(page: Page): Alerts {
page.getByTestId('threshold-value-input').nth(index),
selectThresholdChannel: async (index, channelName) => {
await wheelTo(form.thresholdChannelSelects().nth(index));
await antd.pickByName(
form.thresholdChannelSelects().nth(index),
channelName,
@@ -533,6 +535,7 @@ export function alertsDriver(page: Page): Alerts {
* and without that a second call waits forever for the add button.
*/
addLabel: async (key, value) => {
await wheelTo(page.getByTestId('alert-add-label-button'));
await page.getByTestId('alert-add-label-button').click();
const input = page.getByTestId('alert-add-label-input');
await input.fill(key);
@@ -556,6 +559,7 @@ export function alertsDriver(page: Page): Alerts {
*/
expandAdvancedOptions: async () => {
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
await wheelTo(header);
if ((await header.getAttribute('aria-expanded')) !== 'true') {
await header.click();
}
@@ -570,6 +574,7 @@ export function alertsDriver(page: Page): Alerts {
// The popover's content is only in the DOM while open, so every option
// lookup has to come after this.
openEvaluationSettings: async () => {
await wheelTo(form.evaluationSettingsButton());
await form.evaluationSettingsButton().click();
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
},
@@ -659,6 +664,7 @@ export function alertsDriver(page: Page): Alerts {
openChannelDropdown: async (index = 0) => {
const select = form.thresholdChannelSelects().nth(index);
await wheelTo(select);
await select.click();
const dropdown = await antd.ownDropdown(select);
return {

View File

@@ -7,6 +7,7 @@ import { expect, type Locator, type Page } from '@playwright/test';
import { authToken } from './api';
import { driver, kit, seeded } from './kit';
import { wheelTo } from './scroll';
// Search and the type filter are both served by the API, so every assertion on
// the list is also a guard on the query the list sends.
@@ -316,6 +317,7 @@ function channelsDriver(page: Page): Channels {
await page.getByRole('option', { name: label, exact: true }).click();
},
async fillField(testId, value) {
await wheelTo(page.getByTestId(testId));
await page.getByTestId(testId).fill(value);
},
async saveAndReturnId() {

View File

@@ -11,6 +11,7 @@ import { columnStorageKey } from '../storage';
import { tanstackDriver, type Tanstack } from '../tanstack';
import { K8S_PATH, type EntityDef, type K8sCategory } from './entities';
import { wheelTo } from '../scroll';
/**
* Driving infra monitoring: the list, the drawer, and the state they share.
@@ -347,9 +348,7 @@ export function infraDriver(page: Page): Infra {
// Playwright's auto-scroll aligns to the nearest edge, which parks a
// low row under the sticky pagination bar; centring clears both
// sticky edges, as a user's own scroll would.
await button.evaluate((element) =>
element.scrollIntoView({ block: 'center', inline: 'nearest' }),
);
await wheelTo(button);
await button.click({ timeout: 5_000 });
}
await expect(container).toBeVisible({ timeout: 3_000 });
@@ -361,9 +360,7 @@ export function infraDriver(page: Page): Infra {
collapseGroup: async (groupLabel) => {
const button = list.groupRow(groupLabel).getByTestId('expand-row-button');
if ((await button.getAttribute('aria-expanded')) === 'true') {
await button.evaluate((element) =>
element.scrollIntoView({ block: 'center', inline: 'nearest' }),
);
await wheelTo(button);
await button.click({ timeout: 5_000 });
}
await expect(button).toHaveAttribute('aria-expanded', 'false');
@@ -448,9 +445,7 @@ export function infraDriver(page: Page): Infra {
if (!(await drawerPanel().isVisible())) {
// The header row is `position: sticky`, so a row scrolled "just into
// view" lands under it and the click is swallowed by a header cell.
await row.evaluate((element) =>
element.scrollIntoView({ block: 'center' }),
);
await wheelTo(row);
await row.click({ timeout: 5_000 });
}
await expect(page.getByTestId(DRAWER.close)).toBeVisible({

View File

@@ -15,6 +15,7 @@ import { logDetailDriver, type LogDetail } from './detail';
import { liveTailDriver, type LiveTail } from './liveTail';
import { ROW_SELECTOR } from './rows';
import { seedOne } from './seed';
import { wheelTo } from '../scroll';
export const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
export const LINKED_ROW_CLASS = 'logs-linked-row';
@@ -301,7 +302,7 @@ export function logsExplorerDriver(page: Page): LogsExplorer {
keys.forEach((key) => seen.add(key));
if (seen.size < expected) {
// Virtuoso loads the next page when the last row nears the viewport.
await table.rows().last().scrollIntoViewIfNeeded({ timeout: 2_000 });
await wheelTo(table.rows().last());
await page.mouse.wheel(0, 4_000);
}
expect(seen.size).toBeGreaterThanOrEqual(expected);

View File

@@ -6,6 +6,7 @@ import { expect, type Locator, type Page } from '@playwright/test';
import { PanelKind } from '../dashboard-spec';
import { PATH } from '../dashboards';
import { wheelTo } from '../scroll';
const NEW_PANEL_DRAWER = 'panel-type-drawer';
@@ -29,6 +30,7 @@ export interface NewPanelPicker {
newSection(title: string): Promise<void>;
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
sectionAddPanel(): Locator;
openFromSection(): Promise<void>;
}
export function newPanelDriver(page: Page): NewPanelPicker {
@@ -61,6 +63,10 @@ export function newPanelDriver(page: Page): NewPanelPicker {
},
sectionAddPanel: () =>
page.locator('[data-testid^="section-add-panel-"]').first(),
async openFromSection() {
await wheelTo(picker.sectionAddPanel());
await picker.sectionAddPanel().click();
},
};
return picker;
}

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