mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 07:50:54 +00:00
Compare commits
9 Commits
nv/exec-st
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185e610a1a | ||
|
|
bad36d9cce | ||
|
|
39fa608eaf | ||
|
|
58eec4a28e | ||
|
|
ef1c0ac577 | ||
|
|
427f88ddfc | ||
|
|
c751b86d8d | ||
|
|
fc3b03336b | ||
|
|
711f0b6beb |
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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,20 @@
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
OpenTraceSource,
|
||||
TracesExplorerEvents,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
|
||||
import {
|
||||
listRows,
|
||||
mockSuccess,
|
||||
mockTraceSummary,
|
||||
renderListView,
|
||||
} from './testUtils';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
// 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 +54,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 +120,89 @@ describe('Traces ListView - span drawer navigation', () => {
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab from the row icon without opening the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
// user-event leaves the row on its way to the button, which clears the hover.
|
||||
const user = userEvent.setup({ skipHover: true });
|
||||
renderListView();
|
||||
|
||||
const row = (await screen.findByText('HTTP GET')).closest(
|
||||
'tr',
|
||||
) as HTMLElement;
|
||||
expect(screen.queryByTestId('span-row-open-trace')).not.toBeInTheDocument();
|
||||
await user.hover(row);
|
||||
await user.click(await screen.findByTestId('span-row-open-trace'));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/\/trace\/0000000000000000344ded1387b08a7e\?.*772c4d29dd9076ac/,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
expect(getCurrentLocation().pathname).toBe('/traces-explorer');
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
expect(logEvent).toHaveBeenCalledWith(TracesExplorerEvents.OpenTraceClicked, {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.RowIcon,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab from the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
expect(logEvent).not.toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{ panelType: PANEL_TYPES.LIST, source: OpenTraceSource.Row },
|
||||
);
|
||||
await user.click(await screen.findByTestId('span-drawer-view-full-trace'));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
|
||||
'_blank',
|
||||
);
|
||||
expect(logEvent).toHaveBeenCalledWith(TracesExplorerEvents.OpenTraceClicked, {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.SpanDrawer,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the trace page in a new tab on a Cmd-click without opening the drawer', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
renderListView();
|
||||
|
||||
const row = await screen.findByText('HTTP GET');
|
||||
await user.keyboard('{Meta>}');
|
||||
await user.click(row);
|
||||
await user.keyboard('{/Meta}');
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/trace\/0000000000000000344ded1387b08a7e\?/),
|
||||
'_blank',
|
||||
'noopener',
|
||||
);
|
||||
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
|
||||
expect(logEvent).toHaveBeenCalledWith(TracesExplorerEvents.OpenTraceClicked, {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
source: OpenTraceSource.Row,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the row icon only on the hovered copy of a duplicate span', async () => {
|
||||
mockSuccess([listRows[0], listRows[0]]);
|
||||
// user-event leaves the row on its way to the button, which clears the hover.
|
||||
const user = userEvent.setup({ skipHover: true });
|
||||
renderListView();
|
||||
|
||||
const [first] = await screen.findAllByText('HTTP GET');
|
||||
await user.hover(first.closest('tr') as HTMLElement);
|
||||
|
||||
await expect(
|
||||
screen.findAllByTestId('span-row-open-trace'),
|
||||
).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,11 @@ function ListView({
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
|
||||
const fieldColumns = fields.map((field) =>
|
||||
getFieldColumn(field, PANEL_TYPES.LIST),
|
||||
);
|
||||
return [openTraceColumn, ...fieldColumns];
|
||||
}, [options?.selectColumns, openTraceColumn]);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
@@ -230,10 +176,10 @@ function ListView({
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className={styles.controls}>
|
||||
{headerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
<div className={styles.orderBy}>
|
||||
<div className={styles.orderByLabel}>
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
@@ -249,12 +195,7 @@ function ListView({
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
<TraceExplorerControls config={config} />
|
||||
</div>
|
||||
|
||||
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
|
||||
@@ -273,9 +214,10 @@ function ListView({
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
onEndReached={loadMore}
|
||||
/>
|
||||
|
||||
{drawerProps && <SpanDrawer {...drawerProps} />}
|
||||
<SpanDrawer {...drawerProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,58 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useIsRowHovered } from 'components/TanStackTableView/TanStackTableStateContext';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './OpenTraceButton.module.scss';
|
||||
|
||||
type OpenTraceButtonProps = {
|
||||
row: TracesTableRow;
|
||||
rowId: string;
|
||||
};
|
||||
|
||||
function OpenTraceButton({
|
||||
row,
|
||||
rowId,
|
||||
}: OpenTraceButtonProps): JSX.Element | null {
|
||||
const isHovered = useIsRowHovered(rowId);
|
||||
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
// The row click would open the drawer instead.
|
||||
event.stopPropagation();
|
||||
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.RowIcon);
|
||||
openInNewTab(getTraceLink(row));
|
||||
};
|
||||
|
||||
if (!isHovered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.slot}>
|
||||
<Tooltip title="Open trace details">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
onClick={handleClick}
|
||||
aria-label="Open trace details"
|
||||
testId="span-row-open-trace"
|
||||
>
|
||||
<CornerUpRight size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenTraceButton;
|
||||
@@ -4,7 +4,12 @@ import { ButtonGroup } from '@signozhq/ui/button-group';
|
||||
import { ChevronDown, ChevronUp, CornerUpRight } from '@signozhq/icons';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
import { HeaderAction } from 'components/DetailsPanel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { UseRowNavigationResult } from 'components/TanStackTableView/useRowNavigation';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { SpanDetailVariant } from 'pages/TraceDetailsV3/SpanDetailsPanel/constants';
|
||||
@@ -48,7 +53,10 @@ function SpanDrawer({
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<CornerUpRight />}
|
||||
onClick={(): void => openInNewTab(getTraceLink(row))}
|
||||
onClick={(): void => {
|
||||
logOpenTraceClicked(PANEL_TYPES.LIST, OpenTraceSource.SpanDrawer);
|
||||
openInNewTab(getTraceLink(row));
|
||||
}}
|
||||
testId="span-drawer-view-full-trace"
|
||||
>
|
||||
View full trace
|
||||
|
||||
@@ -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,9 +1,15 @@
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import { Minus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -15,12 +21,15 @@ import {
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
import styles from './FieldCell.module.scss';
|
||||
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
panelType: PANEL_TYPES;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
function FieldCell({ name, value, panelType }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
@@ -37,7 +46,11 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
}
|
||||
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>
|
||||
<Minus size={14} className={styles.emptyValue} data-testid="empty-value" />
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
@@ -47,7 +60,15 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
<AppLink
|
||||
to={buildRoutePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
|
||||
}}
|
||||
onAuxClick={(e): void => {
|
||||
if (e.button === 1) {
|
||||
logOpenTraceClicked(panelType, OpenTraceSource.TraceId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</AppLink>
|
||||
|
||||
@@ -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,19 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useAppNavigate } from 'lib/router/useAppNavigate';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
} from 'components/TanStackTableView/types';
|
||||
import { useRecoverFromEmptyPage } from 'components/TanStackTableView/useRecoverFromEmptyPage';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import {
|
||||
logOpenTraceClicked,
|
||||
OpenTraceSource,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { noop } from 'lodash-es';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { TRACES_TABLE_QUERY_PARAMS } from './constants';
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
import type { TracesTablePagination } from './useTracesTablePagination';
|
||||
import { getRollingTotal, getRowKey } from './utils';
|
||||
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTableProps = {
|
||||
@@ -36,6 +47,9 @@ export type TracesTableProps = {
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
pagination?: TracesTablePagination;
|
||||
/** Loads the next page when the scroll reaches the last row; hides the pager. */
|
||||
onEndReached?: () => void;
|
||||
};
|
||||
|
||||
function TracesTable({
|
||||
@@ -56,9 +70,33 @@ function TracesTable({
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
cellTypographySize = 'medium',
|
||||
pagination,
|
||||
onEndReached,
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
|
||||
// While the next page loads, `data` is still the previous page, so its rows
|
||||
// cannot size the total for the new offset.
|
||||
const settledTotalRef = useRef(0);
|
||||
if (pagination && !isFetching) {
|
||||
settledTotalRef.current = getRollingTotal(
|
||||
pagination.request.offset,
|
||||
data.length,
|
||||
pagination.limit,
|
||||
);
|
||||
}
|
||||
const total = settledTotalRef.current;
|
||||
|
||||
useRecoverFromEmptyPage({
|
||||
page: pagination?.page ?? 1,
|
||||
pageSize: pagination?.limit ?? 0,
|
||||
rowCount: data.length,
|
||||
total,
|
||||
isFetching: isLoading || isFetching,
|
||||
isDisabled: !pagination || isError,
|
||||
setPage: pagination?.setPage ?? noop,
|
||||
});
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
@@ -68,16 +106,18 @@ function TracesTable({
|
||||
onRowClick(row);
|
||||
return;
|
||||
}
|
||||
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
|
||||
navigate(getRowHref(row));
|
||||
},
|
||||
[onRowClick, navigate, getRowHref],
|
||||
[onRowClick, navigate, getRowHref, panelType],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
logOpenTraceClicked(PANEL_TYPES[panelType], OpenTraceSource.Row);
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref],
|
||||
[getRowHref, panelType],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -110,8 +150,19 @@ function TracesTable({
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
isRowActive={isRowActive}
|
||||
activeRowIndex={activeRowIndex}
|
||||
getRowKey={getRowKey}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
testId={`traces-table-${panelType.toLowerCase()}`}
|
||||
onEndReached={onEndReached}
|
||||
enableQueryParams={pagination ? TRACES_TABLE_QUERY_PARAMS : undefined}
|
||||
pagination={
|
||||
pagination && {
|
||||
total,
|
||||
defaultLimit: pagination.limit,
|
||||
showTotalCount: false,
|
||||
onLimitChange: pagination.setLimit,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -128,6 +179,8 @@ TracesTable.defaultProps = {
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
pagination: undefined,
|
||||
onEndReached: undefined,
|
||||
};
|
||||
|
||||
export default TracesTable;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
@@ -10,6 +11,7 @@ export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
panelType: PANEL_TYPES,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
@@ -25,6 +27,8 @@ export function getFieldColumn(
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<FieldCell name={name} value={value} panelType={panelType} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,81 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
OpenTraceSource,
|
||||
TracesExplorerEvents,
|
||||
} from 'container/TracesExplorer/events';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
groupedResponse,
|
||||
groupedRows,
|
||||
QUERY_RANGE_URL,
|
||||
renderTracesView,
|
||||
} from './testUtils';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
describe('TracesView - open trace clicked', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(groupedResponse(groupedRows))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('logs a row click that opens the trace page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
await user.click(await screen.findByText('HTTP GET'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(
|
||||
'/trace/0000000000000000344ded1387b08a7e',
|
||||
),
|
||||
);
|
||||
expect(logEvent).toHaveBeenCalledWith(TracesExplorerEvents.OpenTraceClicked, {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.Row,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a trace_id link click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
await user.click(await screen.findByText('0000000000000000344ded1387b08a7e'));
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(TracesExplorerEvents.OpenTraceClicked, {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.TraceId,
|
||||
});
|
||||
expect(logEvent).not.toHaveBeenCalledWith(
|
||||
TracesExplorerEvents.OpenTraceClicked,
|
||||
{ panelType: PANEL_TYPES.TRACE, source: OpenTraceSource.Row },
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a middle click on the trace_id link', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTracesView();
|
||||
|
||||
await user.pointer({
|
||||
keys: '[MouseMiddle]',
|
||||
target: await screen.findByText('0000000000000000344ded1387b08a7e'),
|
||||
});
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(TracesExplorerEvents.OpenTraceClicked, {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
source: OpenTraceSource.TraceId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
@@ -1,12 +1,10 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
@@ -18,7 +16,7 @@ const TRACE_FIELDS = [
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
...getFieldColumn(field, PANEL_TYPES.TRACE),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
20
frontend/src/container/TracesExplorer/events.ts
Normal file
20
frontend/src/container/TracesExplorer/events.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export enum TracesExplorerEvents {
|
||||
OpenTraceClicked = 'Traces Explorer: Open trace clicked',
|
||||
}
|
||||
|
||||
export enum OpenTraceSource {
|
||||
Row = 'row',
|
||||
RowIcon = 'row_icon',
|
||||
TraceId = 'trace_id',
|
||||
SpanDrawer = 'span_drawer',
|
||||
}
|
||||
|
||||
export function logOpenTraceClicked(
|
||||
panelType: PANEL_TYPES,
|
||||
source: OpenTraceSource,
|
||||
): void {
|
||||
void logEvent(TracesExplorerEvents.OpenTraceClicked, { panelType, source });
|
||||
}
|
||||
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' },
|
||||
|
||||
32
tests/e2e/drivers/traces-explorer.ts
generated
32
tests/e2e/drivers/traces-explorer.ts
generated
@@ -74,8 +74,12 @@ export interface TracesExplorer {
|
||||
/** Re-run the query until `key`'s row renders: the list fetches once. */
|
||||
waitForRow(key: string): Promise<void>;
|
||||
cell(rowKey: string, field: string): Locator;
|
||||
/** Click a list row and wait for its trace-details URL. */
|
||||
openRow(rowKey: string, traceId: string): Promise<void>;
|
||||
/** Click a list row and wait for its span drawer. */
|
||||
openSpanDrawer(rowKey: string): Promise<void>;
|
||||
spanDrawer(): Locator;
|
||||
spanDrawerTraceId(): Locator;
|
||||
/** "View full trace" in the drawer, replayed on this page. */
|
||||
openFullTrace(traceId: string, spanId: string): Promise<void>;
|
||||
/** A waterfall row on the trace-details page. */
|
||||
traceDetailsRow(spanId: string): Locator;
|
||||
timeSeriesGraph(): Locator;
|
||||
@@ -197,11 +201,27 @@ export function tracesExplorerDriver(page: Page): TracesExplorer {
|
||||
|
||||
cell: (rowKey, field) => explorer.row(rowKey).getByTestId(field).first(),
|
||||
|
||||
openRow: async (rowKey, traceId) => {
|
||||
openSpanDrawer: async (rowKey) => {
|
||||
await explorer.cell(rowKey, 'name').click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/trace/${traceId}\\?.*spanId=${rowKey}`),
|
||||
);
|
||||
await expect(explorer.spanDrawer()).toBeVisible();
|
||||
},
|
||||
|
||||
spanDrawer: () => page.getByTestId('span-details-drawer'),
|
||||
|
||||
spanDrawerTraceId: () =>
|
||||
explorer.spanDrawer().getByTestId('span-details-trace-id'),
|
||||
|
||||
openFullTrace: async (traceId, spanId) => {
|
||||
// The button opens trace details in a new tab; replay its URL here so
|
||||
// the driver stays on this page.
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
page.getByTestId('span-drawer-view-full-trace').click(),
|
||||
]);
|
||||
await tab.waitForURL(new RegExp(`/trace/${traceId}\\?.*spanId=${spanId}`));
|
||||
const { pathname, search } = new URL(tab.url());
|
||||
await tab.close();
|
||||
await page.goto(`${pathname}${search}`);
|
||||
},
|
||||
|
||||
traceDetailsRow: (spanId) => page.getByTestId(`cell-0-${spanId}`),
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe('Traces explorer, views', () => {
|
||||
expect(noise.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('TC-02 clicking a list row opens that span in trace details', async ({
|
||||
test('TC-02 a list row opens its span drawer, and View full trace opens that span in trace details', async ({
|
||||
traces,
|
||||
tracesExplorerSeed: seed,
|
||||
}) => {
|
||||
@@ -35,7 +35,10 @@ test.describe('Traces explorer, views', () => {
|
||||
await traces.filterService(seed.service);
|
||||
await traces.waitForRow(seed.errorSpanId);
|
||||
|
||||
await traces.openRow(seed.errorSpanId, seed.traceId);
|
||||
await traces.openSpanDrawer(seed.errorSpanId);
|
||||
await expect(traces.spanDrawerTraceId()).toHaveText(seed.traceId);
|
||||
|
||||
await traces.openFullTrace(seed.traceId, seed.errorSpanId);
|
||||
|
||||
await expect(traces.traceDetailsRow(seed.errorSpanId)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -442,51 +442,3 @@ def test_builder_refreshing_a_sliding_time_range(
|
||||
assert uncached.status_code == HTTPStatus.OK, uncached.text
|
||||
|
||||
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")
|
||||
|
||||
|
||||
def test_builder_rows_scanned_when_refreshing(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
metric_name = f"cache_rows_scanned_{uuid4().hex[:8]}"
|
||||
|
||||
# 90 minutes back so even the last refresh closes clear of the flux
|
||||
# interval, which holds recent data out of the cache
|
||||
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
|
||||
start_time_ms = int(start_time.timestamp() * 1000)
|
||||
|
||||
query = [build_builder_query("A", metric_name, "max", "max")]
|
||||
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels={"service": "api"},
|
||||
timestamp=start_time + timedelta(minutes=minute),
|
||||
value=minute,
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
)
|
||||
for minute in range(70)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# a dashboard left open on a one hour range, re-running a minute later each
|
||||
# time. rowsScanned is what answering the request cost, so a refresh served
|
||||
# mostly out of the cache cannot report more than reading its range afresh
|
||||
for refresh in range(10):
|
||||
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
|
||||
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=False)
|
||||
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
|
||||
|
||||
uncached = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=True)
|
||||
assert uncached.status_code == HTTPStatus.OK, uncached.text
|
||||
|
||||
rows_scanned_from_cache = from_cache.json()["data"]["meta"]["rowsScanned"]
|
||||
rows_scanned_uncached = uncached.json()["data"]["meta"]["rowsScanned"]
|
||||
assert rows_scanned_uncached > 0, f"refresh {refresh}"
|
||||
assert rows_scanned_from_cache <= rows_scanned_uncached, f"refresh {refresh}: {rows_scanned_from_cache} rows scanned when served from the cache, {rows_scanned_uncached} when read afresh"
|
||||
|
||||
Reference in New Issue
Block a user