mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 16:00:41 +00:00
Compare commits
14 Commits
nv/exec-st
...
fix/query-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f4b85a78 | ||
|
|
add47f6516 | ||
|
|
2d3c4fd7d2 | ||
|
|
8f86b59822 | ||
|
|
e53d16157b | ||
|
|
83ebe26877 | ||
|
|
33d2ff917f | ||
|
|
7268d5ebf5 | ||
|
|
13440740c4 | ||
|
|
711f0b6beb | ||
|
|
9fc012d819 | ||
|
|
e17252420c | ||
|
|
ac153c34f3 | ||
|
|
f234da5983 |
4
.github/workflows/e2eci.yaml
vendored
4
.github/workflows/e2eci.yaml
vendored
@@ -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: |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.logsTable {
|
||||
--tanstack-table-row-height: 30px;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.logs-explorer-views-container {
|
||||
margin-bottom: 24px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.emptyValue {
|
||||
display: block;
|
||||
color: var(--l2-border);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
@@ -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);
|
||||
@@ -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. */
|
||||
@@ -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(),
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
205
frontend/src/lib/visualization/charts/Pie/hooks/usePieTooltip.ts
Normal file
205
frontend/src/lib/visualization/charts/Pie/hooks/usePieTooltip.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/src/lib/visualization/charts/Pie/utils/colors.ts
Normal file
41
frontend/src/lib/visualization/charts/Pie/utils/colors.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
15
frontend/src/lib/visualization/charts/Pie/utils/slices.ts
Normal file
15
frontend/src/lib/visualization/charts/Pie/utils/slices.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
&.active {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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, ''));
|
||||
|
||||
13
frontend/src/pages/TracesExplorer/TracesExplorer.module.scss
Normal file
13
frontend/src/pages/TracesExplorer/TracesExplorer.module.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.views {
|
||||
padding: 8px 0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tableView {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.insetView {
|
||||
flex: 1;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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).
|
||||
|
||||
6
tests/e2e/drivers/alerts/driver.ts
generated
6
tests/e2e/drivers/alerts/driver.ts
generated
@@ -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 {
|
||||
|
||||
2
tests/e2e/drivers/channels.ts
generated
2
tests/e2e/drivers/channels.ts
generated
@@ -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() {
|
||||
|
||||
13
tests/e2e/drivers/infra/driver.ts
generated
13
tests/e2e/drivers/infra/driver.ts
generated
@@ -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({
|
||||
|
||||
3
tests/e2e/drivers/logs/explorer.ts
generated
3
tests/e2e/drivers/logs/explorer.ts
generated
@@ -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);
|
||||
|
||||
6
tests/e2e/drivers/panel-editor/picker.ts
generated
6
tests/e2e/drivers/panel-editor/picker.ts
generated
@@ -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
Reference in New Issue
Block a user