Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
ce43780c78 fix(querybuilder): read an unknown body path in a column as a string path
A body. key that metadata does not report kept no data type in the logs
fallback, so a select, group by, order by, or aggregation on it failed with
404 "field not found" instead of the not-found warning. The fallback now types
it as bodyPath does for a bare key. Filters keep their SQL.

Assisted-by: Claude Opus 5.5
2026-11-05 03:46:58 +05:30
123 changed files with 1283 additions and 3348 deletions

View File

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

View File

@@ -18,51 +18,71 @@
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;
.horizontal-line {
margin-inline: -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;
}
.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;
}
}
}
@@ -82,6 +102,31 @@
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 {
@@ -153,10 +198,90 @@
cursor: pointer;
}
}
.max-lines-per-row-input {
display: flex;
.ant-input-number-handler-wrap {
display: none;
}
.ant-input-number {
min-width: 36px;
width: auto;
border-right: none;
border-left: none;
border-top: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
text-align: center;
height: 26px;
border-radius: 0;
&:active,
&:focus {
border: none;
box-shadow: none;
}
}
.ant-input-number-focused {
box-shadow: none !important;
}
.ant-input-number-input-wrap {
input {
text-align: center;
font-size: 13px;
&:active,
&:focus {
border: none;
}
}
&:active,
&:focus {
border: none;
}
}
.periscope-btn {
box-shadow: none;
padding: 6px 12px;
height: 26px;
border-radius: 0px 1px 1px 0px;
background: var(--l3-background);
}
}
}
.edit-columns-container {
padding: 12px;
.edit-columns-btn {
display: flex;
width: 100%;
height: 20px;
padding: 4px 0px;
justify-content: space-between;
align-items: center;
border: none !important;
.edit-columns-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
.edit-columns-btn:hover {
background-color: unset !important;
}
}
.add-new-column-container {

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
LogViewMode,
@@ -12,15 +11,13 @@ 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;
@@ -65,11 +62,25 @@ function OptionsMenu({
onChange(key);
};
const handleLinesPerRowChange = (value: number | null): void => {
if (value !== null) {
setMaxLinesPerRow(
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
);
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);
}
};
@@ -100,36 +111,51 @@ 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"
>
Select font size
<ChevronLeft size={14} className="icon" />
<Typography.Text className="text">Select font size</Typography.Text>
</Button>
<div className="horizontal-line" />
<div className="content">
{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>
))}
<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>
</div>
</div>
) : (
@@ -137,20 +163,14 @@ function OptionsMenu({
<div className="font-size-container">
<div className="title">Font Size</div>
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="font-size"
testId="font-size-select"
className="value"
type="text"
onClick={(): void => {
setIsFontSizeOptionsOpen(true);
}}
>
<span className="menu-row">
{fontSizeValue}
<ChevronRight size={14} />
</span>
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
<ChevronRight size={14} className="icon" />
</Button>
</div>
<div className="horizontal-line" />
@@ -180,20 +200,30 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="max-lines-per-row">
<div className="title"> max lines per row </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 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>
</div>
</>
)}
@@ -203,18 +233,15 @@ function OptionsMenu({
<div className="horizontal-line" />
<div className="edit-columns-container">
<Button
variant="link"
color="secondary"
size="sm"
width="100%"
data-format-menu="edit-columns"
className="edit-columns-btn"
type="text"
onClick={handleEditColumns}
testId="periscope-btn-edit-columns"
data-testid="periscope-btn-edit-columns"
>
<span className="menu-row">
<Typography.Text className="edit-columns-text">
Edit columns
<ChevronRight size={14} />
</span>
</Typography.Text>
<ChevronRight size={14} className="icon" />
</Button>
</div>
</>
@@ -253,15 +280,10 @@ function LogsFormatOptionsMenu({
>
<Tooltip title="Options">
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Options"
testId="periscope-btn-format-options"
>
<SlidersVertical size={14} />
</Button>
className="periscope-btn ghost"
icon={<SlidersVertical size="md" />}
data-testid="periscope-btn-format-options"
/>
</Tooltip>
</Popover>
);

View File

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

View File

@@ -46,4 +46,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
TRACES_SPAN_DRAWER_ENABLED = 'TRACES_SPAN_DRAWER_ENABLED',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,57 +1,82 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SlidersVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Settings } from '@signozhq/icons';
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);
if (!config?.fieldsSelector) {
return null;
}
const label = t('options_menu.options');
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
return (
<>
<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 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}
/>
</>
</div>
);
}
type TraceExplorerControlsProps = {
config?: OptionsMenuConfig | null;
};
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,9 @@
import { getCurrentLocation } from 'lib/router/navigation';
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 { screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
listRows,
mockSuccess,
mockTraceSummary,
renderListView,
} from './testUtils';
import { mockSuccess, mockTraceSummary, renderListView } from './testUtils';
// globalTime starts with loading:true, which gates the list query. Force just that
// slice's loading to false so the query fires; every other selector is untouched.
@@ -46,13 +43,14 @@ 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 () => {
@@ -112,41 +110,4 @@ describe('Traces ListView - span drawer navigation', () => {
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument(),
);
});
it('opens the trace page in a new tab from the row icon without opening the drawer', async () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
// user-event leaves the row on its way to the button, which clears the hover.
const user = userEvent.setup({ skipHover: true });
renderListView();
const row = (await screen.findByText('HTTP GET')).closest(
'tr',
) as HTMLElement;
expect(screen.queryByTestId('span-row-open-trace')).not.toBeInTheDocument();
await user.hover(row);
await user.click(await screen.findByTestId('span-row-open-trace'));
expect(open).toHaveBeenCalledWith(
expect.stringMatching(
/\/trace\/0000000000000000344ded1387b08a7e\?.*772c4d29dd9076ac/,
),
'_blank',
);
expect(getCurrentLocation().pathname).toBe('/traces-explorer');
expect(screen.queryByTestId('span-details-drawer')).not.toBeInTheDocument();
});
it('shows the row icon only on the hovered copy of a duplicate span', async () => {
mockSuccess([listRows[0], listRows[0]]);
// user-event leaves the row on its way to the button, which clears the hover.
const user = userEvent.setup({ skipHover: true });
renderListView();
const [first] = await screen.findAllByText('HTTP GET');
await user.hover(first.closest('tr') as HTMLElement);
await expect(
screen.findAllByTestId('span-row-open-trace'),
).resolves.toHaveLength(1);
});
});

View File

@@ -1,3 +1,6 @@
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';
@@ -51,11 +54,13 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
describe('Traces ListView - span drawer trace bounds', () => {
beforeEach(() => {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
mockSuccess();
mockTraceSummary({});
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
server.resetHandlers();
});

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import ListView from './index';
@@ -59,8 +59,16 @@ 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.getByTestId('periscope-btn-options-traces')).toBeInTheDocument();
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
};
describe('Traces ListView - Error and Empty States', () => {
@@ -145,11 +153,18 @@ describe('Traces ListView - Error and Empty States', () => {
});
// Order by controls should be interactive
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
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();
// Options menu should be clickable
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
});
it('allows interaction with controls in empty state', async () => {
@@ -161,11 +176,12 @@ describe('Traces ListView - Error and Empty States', () => {
});
// All controls should be interactive
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
const comboboxes = screen.getAllByRole('combobox');
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
// Options menu should be clickable
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
const optionsButton = screen.getByText(/options_menu.options|options/i);
expect(optionsButton).toBeInTheDocument();
});
});
});

View File

@@ -20,15 +20,22 @@ 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';
@@ -36,9 +43,14 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { defaultSelectedColumns, TIMESTAMP_FIELD } from './configs';
import { useListViewInfiniteQuery } from './useListViewInfiniteQuery';
import { getTraceLink } from './utils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
TIMESTAMP_FIELD,
} from './configs';
import { getTraceLink, transformSpanRows } from './utils';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
@@ -84,6 +96,12 @@ 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(
@@ -107,26 +125,51 @@ function ListView({
minTime,
stagedQuery,
panelType,
paginationConfig,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
maxTime,
minTime,
],
[stagedQuery, panelType, globalSelectedTime, maxTime, minTime],
);
if (queryKeyRef) {
queryKeyRef.current = queryKey;
}
const { rows, warning, isFetching, isLoading, isError, error, loadMore } =
useListViewInfiniteQuery({
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
{
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(() => {
setWarning(warning);
}, [warning, setWarning]);
if (data?.payload) {
setWarning(data?.warning);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.payload, data?.warning]);
useEffect(() => {
if (isLoading || isFetching) {
@@ -136,8 +179,15 @@ function ListView({
}
}, [isLoading, isFetching, setIsLoadingQueries]);
const { tableProps, openTraceColumn, drawerProps, onPressInside } =
useSpanDrawer(rows);
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 columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
@@ -146,9 +196,15 @@ function ListView({
(field) => field.name !== TIMESTAMP_FIELD.name,
),
];
const fieldColumns = fields.map((field) => getFieldColumn(field));
return [openTraceColumn, ...fieldColumns];
}, [options?.selectColumns, openTraceColumn]);
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const { tableProps, drawerProps, onPressInside } = useSpanDrawer(rows);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TracesTableRow>[]): void => {
@@ -174,10 +230,10 @@ function ListView({
return (
<div className={styles.container}>
<div className={styles.controls}>
<div className="trace-explorer-controls">
{headerActions}
<div className={styles.orderBy}>
<div className={styles.orderByLabel}>
<div className="order-by-container">
<div className="order-by-label">
Order by <Minus size={14} /> <ArrowUp10 size={14} />
</div>
@@ -193,7 +249,12 @@ function ListView({
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls config={config} />
<TraceExplorerControls
isLoading={isFetching}
totalCount={totalCount}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
{/* A press in the table or the drawer (a portal) keeps the drawer open. */}
@@ -212,10 +273,9 @@ function ListView({
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
onEndReached={loadMore}
/>
<SpanDrawer {...drawerProps} />
{drawerProps && <SpanDrawer {...drawerProps} />}
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,20 @@
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(() => {
mockQueryId = 'query-1';
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
});
afterEach(() => {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
});
it('does not reopen a span that a refetch dropped and a later one brings back', () => {
@@ -30,15 +32,4 @@ describe('useSpanDrawer', () => {
rerender({ current: rows });
expect(result.current.drawerProps?.row).toBeNull();
});
it('closes on a new query while the old rows are still shown', () => {
const { result, rerender } = renderHook(() => useSpanDrawer(rows));
act(() => result.current.tableProps.onRowClick?.(rows[1]));
expect(result.current.drawerProps?.row).toBe(rows[1]);
mockQueryId = 'query-2';
rerender();
expect(result.current.drawerProps?.row).toBeNull();
});
});

View File

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

View File

@@ -1,20 +1,11 @@
import {
PointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { PointerEvent, useCallback, useEffect, useMemo, useState } from 'react';
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 };
@@ -23,14 +14,14 @@ type UseSpanDrawerResult = {
TracesTableProps,
'onRowClick' | 'isRowActive' | 'activeRowIndex'
>;
/** Leading column that opens the trace page. */
openTraceColumn: TableColumnDef<TracesTableRow>;
drawerProps: SpanDrawerProps;
/** Null while the drawer flag is off. */
drawerProps: SpanDrawerProps | null;
/** `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);
@@ -69,19 +60,8 @@ 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(
activeRowIndex !== -1,
isEnabled && activeRowIndex !== -1,
clearActiveSpan,
);
@@ -91,13 +71,12 @@ export function useSpanDrawer(rows: TracesTableRow[]): UseSpanDrawerResult {
onNavigate: handleNavigate,
});
if (!isEnabled) {
return { tableProps: {}, drawerProps: null, onPressInside };
}
return {
tableProps: {
onRowClick: selectRow,
isRowActive,
activeRowIndex,
},
openTraceColumn: OPEN_TRACE_COLUMN,
tableProps: { onRowClick: selectRow, isRowActive, activeRowIndex },
drawerProps: {
row: rows[activeRowIndex] ?? null,
onClose: clearActiveSpan,

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
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';
@@ -16,8 +15,6 @@ import {
} from './constants';
import { stringifyCellValue } from './utils';
import styles from './FieldCell.module.scss';
type FieldCellProps = {
name: string;
value: unknown;
@@ -40,11 +37,7 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
}
if (value === '' || value == null) {
return (
<TanStackTable.Text data-testid={name}>
<Minus size={14} className={styles.emptyValue} data-testid="empty-value" />
</TanStackTable.Text>
);
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);

View File

@@ -6,7 +6,21 @@
}
.tracesTable {
--tanstack-table-row-height: 30px;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l3-background-60);
--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);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react';
import { useCallback } from 'react';
import { useAppNavigate } from 'lib/router/useAppNavigate';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
@@ -6,20 +6,14 @@ import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import { useRecoverFromEmptyPage } from 'components/TanStackTableView/useRecoverFromEmptyPage';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { noop } from 'lodash-es';
import { getAbsoluteUrl } from 'utils/basePath';
import { TRACES_TABLE_QUERY_PARAMS } from './constants';
import type { TracesTableRow } from './getFieldColumn';
import type { TracesTablePagination } from './useTracesTablePagination';
import { getRollingTotal, getRowKey } from './utils';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
@@ -42,9 +36,6 @@ 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({
@@ -65,33 +56,9 @@ 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;
@@ -143,19 +110,8 @@ 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>
)}
@@ -172,8 +128,6 @@ TracesTable.defaultProps = {
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
pagination: undefined,
onEndReached: undefined,
};
export default TracesTable;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@ import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
const TRACE_FIELDS = [
{ name: 'service.name', fieldContext: 'resource' },

View File

@@ -1,13 +1,42 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen, waitFor } from 'tests/test-utils';
import {
groupedResponse,
groupedRows,
QUERY_RANGE_URL,
renderTracesView,
} from './__tests__/testUtils';
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 }] } },
});
const mockSuccess = (rows: unknown[] = groupedRows): void => {
server.use(
@@ -25,6 +54,29 @@ 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();
@@ -44,8 +96,8 @@ describe('TracesView (grouped root-span table)', () => {
// span_count renders as text
expect(screen.getByText('8')).toBeInTheDocument();
// empty field renders the dash icon
expect(screen.getAllByTestId('empty-value').length).toBeGreaterThanOrEqual(1);
// empty field renders "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
// trace_id renders as a link to the trace detail
const traceLinks = screen.getAllByTestId('trace-id');
@@ -63,6 +115,8 @@ 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();
@@ -76,5 +130,7 @@ describe('TracesView (grouped root-span table)', () => {
expect(
screen.getByText(/This tab only shows Root Spans/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,7 @@ import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import AlertHeader from './AlertHeader/AlertHeader';
import AlertNotFound from './AlertNotFound';
import { useGetAlertRuleDetails } from './hooks';
import { useRouteTabUtils } from './useRouteTabUtils';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
import './AlertDetails.styles.scss';

View File

@@ -1,53 +0,0 @@
import { renderHook } from '@testing-library/react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAlertHistoryQueryParams } from '../hooks';
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
function renderWithSearch(
search: string,
): ReturnType<typeof useAlertHistoryQueryParams> {
jest.mocked(useUrlQuery).mockReturnValue(new URLSearchParams(search));
return renderHook(() => useAlertHistoryQueryParams()).result.current;
}
describe('useAlertHistoryQueryParams', () => {
it.each(['1y', '90s', 'now-1h', 'custom', 'null', ''])(
'falls back to the default range for relativeTime=%p',
(param) => {
const { startTime, endTime, relativeTime } = renderWithSearch(
`ruleId=1&relativeTime=${param}`,
);
expect(endTime - startTime).toBeCloseTo(THIRTY_MINUTES_MS, -3);
expect(relativeTime).toBe('30m');
},
);
it('uses a valid relativeTime', () => {
const { startTime, endTime, relativeTime } = renderWithSearch(
'ruleId=1&relativeTime=6h',
);
expect(endTime - startTime).toBeCloseTo(6 * 60 * 60 * 1000, -3);
expect(relativeTime).toBe('6h');
});
it('prefers startTime and endTime over relativeTime', () => {
const { startTime, endTime, relativeTime } = renderWithSearch(
'ruleId=1&relativeTime=1y&startTime=1000&endTime=2000',
);
expect({ startTime, endTime, relativeTime }).toStrictEqual({
startTime: 1000,
endTime: 2000,
relativeTime: null,
});
});
});

View File

@@ -29,21 +29,27 @@ import {
type RuletypesPostableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import {
computeCursorForPage,
useTimelineTableOrder,
useTimelineTablePage,
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
import { TimelineFilter } from 'container/AlertHistory/types';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax, { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import GetMinMax from 'lib/getMinMax';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import { navigate } from 'lib/router/navigation';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
@@ -57,7 +63,6 @@ export const useAlertHistoryQueryParams = (): {
startTime: number;
endTime: number;
hasStartAndEndParams: boolean;
relativeTime: string | null;
params: URLSearchParams;
} => {
const params = useUrlQuery();
@@ -67,9 +72,8 @@ export const useAlertHistoryQueryParams = (): {
const relativeTimeParam = params.get(QueryParams.relativeTime);
const relativeTime =
relativeTimeParam && isValidShortHandDateTimeFormat(relativeTimeParam)
? relativeTimeParam
: DEFAULT_TIME_RANGE;
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
DEFAULT_TIME_RANGE;
const intStartTime = parseInt(startTime || '0', 10);
const intEndTime = parseInt(endTime || '0', 10);
@@ -89,10 +93,63 @@ export const useAlertHistoryQueryParams = (): {
startTime: Math.floor(nanoToMilli(minTime)),
endTime: Math.floor(nanoToMilli(maxTime)),
hasStartAndEndParams,
relativeTime: hasStartAndEndParams ? null : relativeTime,
params,
};
};
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${buildRoutePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};
type Props = {
ruleId: string | null;
isValidRuleId: boolean;
@@ -299,7 +356,12 @@ export const useTimelineTable = ({
nextCursor?: string;
}): {
paginationConfig: TablePaginationConfig;
onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'];
onChangeHandler: (
pagination: TablePaginationConfig,
sorter: any,
filters: any,
extra: any,
) => void;
handleNextPage: () => void;
handlePrevPage: () => void;
hasNextPage: boolean;
@@ -386,11 +448,8 @@ export const useAlertRuleStatusToggle = ({
{
onSuccess: (data) => {
setAlertRuleState(data.data.state);
void invalidateGetRuleByID(queryClient, { id: ruleId });
void queryClient.refetchQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]);
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
notifications.success({
message: `Alert has been ${
data.data.state === 'disabled' ? 'disabled' : 'enabled'
@@ -398,11 +457,8 @@ export const useAlertRuleStatusToggle = ({
});
},
onError: (error) => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void queryClient.refetchQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]);
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);

View File

@@ -1,65 +0,0 @@
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { AlertDetailsTab } from 'container/AlertHistory/types';
import useUrlQuery from 'hooks/useUrlQuery';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${buildRoutePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@
*/
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';
@@ -106,9 +109,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 list loads 100 more as it scrolls to the end; the Trace view pages 20 at a time.',
'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.',
value: 24,
max: 250,
max: 40,
}),
errors: toggleControl('Failing spans', {
group: LIST,
@@ -116,6 +119,12 @@ 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:
@@ -353,4 +362,11 @@ export const tracesMocks = defineStoryMocks({
),
],
config: (values) => ({ route: tracesRoute(values) }),
effect: (values) => {
if (values.spanDrawer) {
set(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED, 'true');
} else {
remove(LOCALSTORAGE.TRACES_SPAN_DRAWER_ENABLED);
}
},
});

View File

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

View File

@@ -25,6 +25,7 @@ const meta = {
component: TracesModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
args: { spanDrawer: true },
} satisfies Meta<TracesArgs>;
export default meta;
@@ -36,8 +37,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 findOrdersRow = (): Promise<HTMLElement> =>
waitFor((): HTMLElement => {
const openDrawer = async (): Promise<HTMLElement> => {
const row = await waitFor((): HTMLElement => {
const match = screen
.getAllByTestId(/^traces-table-row-/)
.find((candidate) => candidate.textContent?.includes('POST /api/orders'));
@@ -47,9 +48,6 @@ const findOrdersRow = (): 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');
@@ -82,19 +80,6 @@ export const Default: Story = {
},
};
/**
* A hovered row with the icon at its start that opens the trace page in a new
* tab, without opening the drawer.
*/
export const OpenTraceIcon: Story = {
play: async ({ mount }): Promise<void> => {
await mount();
await userEvent.hover(await findOrdersRow());
await expect(await screen.findByTestId('span-row-open-trace')).toBeVisible();
},
};
/** The Logs tab with the span's logs, between the logs just before and after them. */
export const Logs: Story = {
args: { spanLogs: 'span' },

View File

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

View File

@@ -0,0 +1,86 @@
package querybuilder_test
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// A key metadata does not report warns and the query runs, so in a select
// field, group by, order by, or aggregation every storage's fallback must read.
// The only failure allowed is invalid input for a context the signal does not
// serve.
func TestResolveColumn_UnknownKey(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
start := uint64(releaseTime.Add(24 * time.Hour).UnixNano())
end := start + uint64(time.Hour.Nanoseconds())
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
testCases := []struct {
name string
storage qbtypes.Storage
query qbtypes.QueryInfo
}{
{
name: "Logs",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
{
name: "Logs_JSONBody",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end, BodyJSONOn: true},
},
{
name: "Traces",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end},
},
{
name: "Traces_JSONAttributes",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end, TraceAttrsJSONOn: true},
},
{
name: "Metrics",
storage: metricstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalMetrics, StartNs: start, EndNs: end},
},
{
name: "Audit",
storage: audittelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
}
for _, testCase := range testCases {
for _, value := range (telemetrytypes.FieldContext{}).Enum() {
fieldContext, ok := value.(telemetrytypes.FieldContext)
require.True(t, ok)
t.Run(testCase.name+"_"+fieldContext.StringValue(), func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "unknown.key", FieldContext: fieldContext, Signal: testCase.query.Signal}
_, err := querybuilder.ResolveColumn(context.Background(), testCase.query, testCase.storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
if err != nil {
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "%v", err)
}
})
}
}
}

View File

@@ -505,7 +505,7 @@ func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetr
var keys []*telemetrytypes.TelemetryFieldKey
switch key.FieldContext {
case telemetrytypes.FieldContextBody:
keys = []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, key.FieldDataType)}
keys = []*telemetrytypes.TelemetryFieldKey{bodyPath(key.Name, key.FieldDataType, value)}
case telemetrytypes.FieldContextUnspecified:
if column, ok := logsV2Columns[key.Name]; ok {
keys = []*telemetrytypes.TelemetryFieldKey{{Name: key.Name, FieldContext: telemetrytypes.FieldContextLog, FieldDataType: querybuilder.ColumnDataType(column)}}

View File

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

View File

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

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