Compare commits

..

8 Commits

146 changed files with 2242 additions and 4018 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

@@ -4377,7 +4377,8 @@ components:
description: The dashboard title and description.
duration:
description: The default time range, as a Prometheus duration such as 1h
or 7d. Stored, but not applied by the UI.
or 7d. The UI opens the dashboard on it, rounded up to whole minutes,
when neither the URL nor the viewer's last pick sets a time range.
type: string
layouts:
description: The Grid layouts that place panels, at most 500. Each panel
@@ -28287,12 +28288,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28301,15 +28296,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- VIEWER
- tokenizer:
- rule:read
- logs-field:list
- traces-field:list
- metrics-field:list
- VIEWER
summary: Get rule history filter keys
tags:
- rules
@@ -28477,12 +28466,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28491,15 +28474,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- VIEWER
- tokenizer:
- rule:read
- logs-field:read
- traces-field:read
- metrics-field:read
- VIEWER
summary: Get rule history filter values
tags:
- rules
@@ -28572,9 +28549,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- VIEWER
- tokenizer:
- rule:read
- VIEWER
summary: Get rule overall status timeline
tags:
- rules
@@ -28644,9 +28621,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- VIEWER
- tokenizer:
- rule:read
- VIEWER
summary: Get rule history stats
tags:
- rules
@@ -28728,12 +28705,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28742,19 +28713,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
- tokenizer:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
summary: Get rule history timeline
tags:
- rules
@@ -28819,12 +28780,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -28833,19 +28788,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
- tokenizer:
- rule:read
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- VIEWER
summary: Get top contributors to rule firing
tags:
- rules

View File

@@ -7704,7 +7704,7 @@ export interface DashboardtypesDashboardSpecDTO {
display: DashboardtypesDisplayDTO;
/**
* @type string
* @description The default time range, as a Prometheus duration such as 1h or 7d. Stored, but not applied by the UI.
* @description The default time range, as a Prometheus duration such as 1h or 7d. The UI opens the dashboard on it, rounded up to whole minutes, when neither the URL nor the viewer's last pick sets a time range.
*/
duration?: string;
/**

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

@@ -13,7 +13,6 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',

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

@@ -1,31 +1,18 @@
import {
PROMETHEUS_DURATION_UNIT_MS,
prometheusDurationToMs,
} from 'utils/prometheusDuration';
import { ChannelFormValues, WebhookAuth } from './types';
// mirrors go's prometheus model.Duration units
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
w: 604_800_000,
y: 31_536_000_000,
};
const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
const JIRA_MIN_REOPEN_MS = 60_000;
export const isValidJiraReopenDuration = (value: string): boolean => {
if (!value || value === '0') {
return true;
}
if (!JIRA_DURATION_RE.test(value)) {
return false;
}
let totalMs = 0;
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
totalMs += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
}
return totalMs >= JIRA_MIN_REOPEN_MS;
const totalMs = prometheusDurationToMs(value);
return totalMs !== null && totalMs >= JIRA_MIN_REOPEN_MS;
};
/**
@@ -33,25 +20,23 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
* back: "72h" becomes "3d", "7d" becomes "1w".
*/
const JIRA_DURATION_FORMAT: { unit: string; ms: number; exact: boolean }[] = [
{ unit: 'y', ms: JIRA_DURATION_UNIT_MS.y, exact: true },
{ unit: 'w', ms: JIRA_DURATION_UNIT_MS.w, exact: true },
{ unit: 'd', ms: JIRA_DURATION_UNIT_MS.d, exact: false },
{ unit: 'h', ms: JIRA_DURATION_UNIT_MS.h, exact: false },
{ unit: 'm', ms: JIRA_DURATION_UNIT_MS.m, exact: false },
{ unit: 's', ms: JIRA_DURATION_UNIT_MS.s, exact: false },
{ unit: 'ms', ms: JIRA_DURATION_UNIT_MS.ms, exact: false },
{ unit: 'y', ms: PROMETHEUS_DURATION_UNIT_MS.y, exact: true },
{ unit: 'w', ms: PROMETHEUS_DURATION_UNIT_MS.w, exact: true },
{ unit: 'd', ms: PROMETHEUS_DURATION_UNIT_MS.d, exact: false },
{ unit: 'h', ms: PROMETHEUS_DURATION_UNIT_MS.h, exact: false },
{ unit: 'm', ms: PROMETHEUS_DURATION_UNIT_MS.m, exact: false },
{ unit: 's', ms: PROMETHEUS_DURATION_UNIT_MS.s, exact: false },
{ unit: 'ms', ms: PROMETHEUS_DURATION_UNIT_MS.ms, exact: false },
];
/** The API rejects a duration it would print differently. */
export const canonicalJiraReopenDuration = (value: string): string => {
if (!value || value === '0' || !JIRA_DURATION_RE.test(value)) {
const totalMs = value && value !== '0' ? prometheusDurationToMs(value) : null;
if (totalMs === null) {
return value;
}
let remaining = 0;
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
remaining += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
}
let remaining = totalMs;
if (remaining === 0) {
return '0s';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,12 +1,12 @@
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 { OptionsMenuConfig } from 'container/OptionsMenu/types';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
config,
}: TraceExplorerControlsProps): JSX.Element | null {
@@ -17,23 +17,15 @@ function TraceExplorerControls({
return null;
}
const label = t('options_menu.options');
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>
<div className={styles.container}>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
@@ -42,7 +34,7 @@ function TraceExplorerControls({
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
</div>
);
}

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,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';
@@ -60,7 +60,7 @@ const verifyControlsVisibility = (): void => {
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
// 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', () => {
@@ -148,8 +148,8 @@ describe('Traces ListView - Error and Empty States', () => {
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
// 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 () => {
@@ -164,8 +164,8 @@ describe('Traces ListView - Error and Empty States', () => {
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
// 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

@@ -40,6 +40,8 @@ import { defaultSelectedColumns, TIMESTAMP_FIELD } from './configs';
import { useListViewInfiniteQuery } from './useListViewInfiniteQuery';
import { getTraceLink } from './utils';
import './ListView.styles.scss';
import styles from './ListView.module.scss';
interface ListViewProps {
@@ -174,10 +176,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>

View File

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

View File

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

@@ -158,7 +158,7 @@ function TracesView({
</Typography.Link>
</Typography>
<div className={styles.controls}>
<div className="trace-explorer-controls">
{headerActions}
<DownloadOptionsMenu
dataSource={DataSource.TRACES}

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

@@ -107,9 +107,13 @@ function JsonEditorDrawer({
} else if (!readOnly && !validity.valid) {
applyDisabledReason = 'Fix the JSON errors first';
}
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
const errorText = validity.message ?? 'Invalid JSON';
let validationText = `Valid JSON · ${validity.lineCount} lines`;
if (!validity.valid) {
validationText = validity.errorLine
? `Line ${validity.errorLine} · ${errorText}`
: errorText;
}
const plural = (n: number): string => (n === 1 ? '' : 's');
const danglingWarning =
danglingPanelIds.length > 0
@@ -135,15 +139,17 @@ function JsonEditorDrawer({
footer={
<div className={styles.footer}>
<div className={styles.footerStatus}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<Tooltip title={validity.valid ? undefined : validationText}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
</Tooltip>
{danglingWarning && (
<Tooltip title={danglingPanelIds.join(', ')}>
<span

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
@@ -170,6 +170,36 @@ describe('JsonEditorDrawer', () => {
);
});
it('shows only the message when the error line is unknown', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({
validity: { valid: false, lineCount: 4, message: 'Bad value' },
}),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
/^Bad value$/,
);
});
it('shows the whole error on hover', async () => {
const message = 'A long error that the footer cuts off';
mockUseJsonEditor.mockReturnValue(
hookValue({
validity: { valid: false, lineCount: 4, errorLine: 2, message },
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
await user.hover(screen.getByTestId('json-editor-validation'));
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
`Line 2 · ${message}`,
);
});
it('disables Apply when not dirty, invalid, or saving', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
const { rerender } = render(

View File

@@ -95,6 +95,54 @@ describe('useJsonEditor', () => {
expect(result.current.isDirty).toBe(true);
});
const withDuration = (duration: unknown): string =>
JSON.stringify({ ...redacted, spec: { ...redacted.spec, duration } });
it.each(['1s', '90s', '500ms', '0m', 'abc', 30])(
'rejects a spec.duration of %p the time picker cannot open',
(duration) => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft(withDuration(duration)));
expect(result.current.validity).toStrictEqual({
valid: false,
lineCount: 1,
errorLine: 1,
message:
'"spec.duration" must be a whole number of minutes above zero, like 30m, 6h or 1d. The time picker has no seconds.',
});
},
);
it.each([
['spec.duration', '{\n "spec": {\n "duration": "1s"\n }\n}', 3],
['image', '{\n "spec": {},\n "image": "https://x.io/a.png"\n}', 3],
])('points at the line of an invalid %s', (_field, draft, errorLine) => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft(draft));
expect(result.current.validity.errorLine).toBe(errorLine);
});
it.each(['', '30m', '1h30m', '60s', '1y'])(
'accepts a spec.duration of %p',
(duration) => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft(withDuration(duration)));
expect(result.current.validity.valid).toBe(true);
},
);
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),

View File

@@ -0,0 +1,15 @@
import { escapeRegExp } from 'lodash-es';
// ponytail: a text match, not a JSON path, so an identical `"key": value` earlier
// in the document wins. Switch to a JSON tree parser (jsonc-parser) if that bites.
export function lineOfJsonEntry(
source: string,
key: string,
value: unknown,
): number | undefined {
const entry = new RegExp(
`"${escapeRegExp(key)}"\\s*:\\s*${escapeRegExp(JSON.stringify(value))}`,
);
const index = source.search(entry);
return index < 0 ? undefined : source.slice(0, index).split('\n').length;
}

View File

@@ -12,8 +12,10 @@ import { toAPIError } from 'utils/errorUtils';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { lineOfJsonEntry } from './lineOfJsonEntry';
import { compactSpecLayouts } from '../../layoutCompaction';
import { isValidDashboardImage } from '../../dashboardIcons';
import { dashboardDurationError } from '../../utils/dashboardDuration';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -109,7 +111,7 @@ export function useJsonEditor({
const validity = useMemo<JsonValidity>(() => {
const lineCount = draft.split('\n').length;
let parsed: { image?: unknown };
let parsed: { image?: unknown; spec?: { duration?: unknown } };
try {
parsed = JSON.parse(draft);
} catch (error) {
@@ -131,10 +133,21 @@ export function useJsonEditor({
return {
valid: false,
lineCount,
errorLine: lineOfJsonEntry(draft, 'image', image),
message:
'"image" must be an /assets/Icons/<name> or /assets/Logos/<name> path, or a base64 image data URI',
};
}
const duration = parsed.spec?.duration;
const durationError = dashboardDurationError(duration);
if (durationError) {
return {
valid: false,
lineCount,
errorLine: lineOfJsonEntry(draft, 'duration', duration),
message: durationError,
};
}
return { valid: true, lineCount };
}, [draft]);

View File

@@ -0,0 +1,30 @@
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
}
.defaultTimeRangeInput {
[data-radix-popper-content-wrapper] {
z-index: 1100 !important;
}
// Radix sets --radix-select-trigger-width on the content element (the wrapper's
// child), so match it there to make the dropdown take the trigger's width.
[data-radix-popper-content-wrapper] > * {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
}
.defaultTimeRangeSelect {
width: 100%;
}
.infoHint {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,41 @@
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { buildDefaultTimeRangeItems, DEFAULT_TIME_RANGE_UNSET } from '../utils';
import settingsStyles from '../../DashboardSettings.module.scss';
import styles from './DefaultTimeRange.module.scss';
interface DefaultTimeRangeProps {
value: string;
onChange: (value: string) => void;
}
function DefaultTimeRange({
value,
onChange,
}: DefaultTimeRangeProps): JSX.Element {
return (
<div className={settingsStyles.settingsCard}>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Default time range</Typography>
<section className={styles.defaultTimeRangeInput}>
<SelectSimple
className={styles.defaultTimeRangeSelect}
testId="dashboard-default-time-range"
items={buildDefaultTimeRangeItems(value)}
value={value && value !== '30m' ? value : DEFAULT_TIME_RANGE_UNSET}
withPortal={false}
onChange={(value): void =>
onChange(value === DEFAULT_TIME_RANGE_UNSET ? '' : (value as string))
}
/>
</section>
<Typography className={styles.infoHint}>
Applies by default when a viewer opens the dashboard for the first time.
</Typography>
</div>
</div>
);
}
export default DefaultTimeRange;

View File

@@ -0,0 +1,131 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import Overview from '../index';
const mockPatchAsync = jest.fn();
jest.mock('../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): { patchAsync: jest.Mock; isPatching: boolean } => ({
patchAsync: mockPatchAsync,
isPatching: false,
}),
}));
jest.mock('../CrossPanelSync/CrossPanelSync', () => ({
__esModule: true,
default: (): null => null,
}));
function buildDashboard(
duration?: string,
): DashboardtypesGettableDashboardV2DTO {
return {
id: 'dash-1',
name: 'D',
orgId: 'org',
source: DashboardtypesSourceDTO.user,
locked: false,
schemaVersion: 'v6',
tags: [],
updatedAt: '2026-01-01T00:00:00Z',
spec: {
display: { name: 'D' },
panels: {},
layouts: [],
variables: [],
duration,
},
};
}
describe('Dashboard settings - default time range', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
async function selectOption(label: string): Promise<void> {
await user.click(screen.getByTestId('dashboard-default-time-range'));
await user.click(await screen.findByText(label));
}
async function save(): Promise<void> {
await user.click(await screen.findByTestId('save-dashboard-config'));
}
beforeEach(() => {
mockPatchAsync.mockReset();
mockPatchAsync.mockResolvedValue(undefined);
});
it('shows the stored window and patches a new one', async () => {
render(<Overview dashboard={buildDashboard('30m')} />);
const trigger = await screen.findByTestId('dashboard-default-time-range');
expect(trigger).toHaveTextContent('Default (Last 30 minutes)');
await selectOption('Last 1 hour');
await expect(screen.findByText('1 unsaved change')).resolves.toBeVisible();
await save();
await waitFor(() => {
expect(mockPatchAsync).toHaveBeenCalledWith([
{ op: 'replace', path: '/spec/duration', value: '1h' },
]);
});
});
it('offers the 30-minute default once', async () => {
render(<Overview dashboard={buildDashboard('1h')} />);
await user.click(await screen.findByTestId('dashboard-default-time-range'));
expect(
screen.getByRole('option', { name: 'Default (Last 30 minutes)' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Last 30 minutes', exact: true }),
).not.toBeInTheDocument();
});
it('patches an empty window when the setting is cleared', async () => {
render(<Overview dashboard={buildDashboard('1h')} />);
await selectOption('Default (Last 30 minutes)');
await save();
await waitFor(() => {
expect(mockPatchAsync).toHaveBeenCalledWith([
{ op: 'replace', path: '/spec/duration', value: '' },
]);
});
});
it.each([
['2h', 'Last 2h'],
['1s', 'Last 1s (opens as 1m)'],
['1h30m', 'Last 1h30m (opens as 90m)'],
['0m', '0m (not applied)'],
])(
'labels a stored %s that is not one of the options as "%s"',
async (duration, label) => {
render(<Overview dashboard={buildDashboard(duration)} />);
const trigger = await screen.findByTestId('dashboard-default-time-range');
expect(trigger).toHaveTextContent(label);
},
);
it('does not patch the window when it is untouched', async () => {
render(<Overview dashboard={buildDashboard('1h')} />);
await user.type(await screen.findByTestId('dashboard-name'), 'X');
await save();
await waitFor(() => {
expect(mockPatchAsync).toHaveBeenCalledWith([
{ op: 'replace', path: '/spec/display/name', value: 'DX' },
]);
});
});
});

View File

@@ -13,6 +13,7 @@ import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
import DefaultTimeRange from './DefaultTimeRange/DefaultTimeRange';
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
import { stringsToTags, tagsToStrings } from './utils';
@@ -31,6 +32,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
const title = dashboard.spec.display.name;
const description = dashboard.spec.display.description ?? '';
const image = dashboard.image || DEFAULT_DASHBOARD_ICON_PATH;
const defaultTimeRange = dashboard.spec.duration ?? '';
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard.tags ?? []),
[dashboard.tags],
@@ -41,6 +43,8 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
const [updatedDescription, setUpdatedDescription] =
useState<string>(description);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [updatedDefaultTimeRange, setUpdatedDefaultTimeRange] =
useState<string>(defaultTimeRange);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
useState<number>(0);
@@ -53,6 +57,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDefaultTimeRange(defaultTimeRange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
@@ -102,6 +107,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
}
if (updatedDefaultTimeRange !== defaultTimeRange) {
ops.push(replace('/spec/duration', updatedDefaultTimeRange));
}
return ops;
}, [
updatedTitle,
@@ -113,6 +121,8 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
dashboard.image,
updatedTags,
tagsAsStrings,
updatedDefaultTimeRange,
defaultTimeRange,
]);
const onSaveHandler = useCallback(async (): Promise<void> => {
@@ -135,12 +145,19 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
useEffect(() => {
let numberOfUnsavedChanges = 0;
const initialValues = [title, description, tagsAsStrings, image];
const initialValues = [
title,
description,
tagsAsStrings,
image,
defaultTimeRange,
];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
updatedDefaultTimeRange,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {
@@ -153,10 +170,12 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
image,
tagsAsStrings,
title,
defaultTimeRange,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
updatedDefaultTimeRange,
]);
const discardHandler = useCallback((): void => {
@@ -164,8 +183,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDescription(description);
setUpdatedDefaultTimeRange(defaultTimeRange);
void logEvent(DashboardDetailEvents.OverviewDiscarded, { dashboardId: id });
}, [title, image, tagsAsStrings, description, id]);
}, [title, image, tagsAsStrings, description, defaultTimeRange, id]);
return (
<div className={styles.overviewContent}>
@@ -179,6 +199,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
onImageChange={setUpdatedImage}
onTagsChange={setUpdatedTags}
/>
<DefaultTimeRange
value={updatedDefaultTimeRange}
onChange={setUpdatedDefaultTimeRange}
/>
<CrossPanelSync dashboardId={id} />
{numberOfUnsavedChanges > 0 && (
<UnsavedChangesFooter

View File

@@ -1,4 +1,7 @@
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import { toPickerRelativeTime } from '../../utils/dashboardDuration';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
@@ -22,3 +25,41 @@ export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
})
.filter((t) => t.key.length > 0);
}
// Radix rejects an empty option value, so "not set" travels through the select
// under a sentinel and is mapped back to '' on the way out.
export const DEFAULT_TIME_RANGE_UNSET = '__unset__';
// The default entry represents 30m; the API rejects `1month`, which is not a
// Prometheus duration.
const SELECTABLE_DURATIONS = RelativeDurationOptions.filter(
(option) => option.value !== '1month' && option.value !== '30m',
);
function storedDurationLabel(value: string): string {
const relativeTime = toPickerRelativeTime(value);
if (!relativeTime) {
return `${value} (not applied)`;
}
return relativeTime === value
? `Last ${value}`
: `Last ${value} (opens as ${relativeTime})`;
}
// A window set through the API or the JSON editor may sit outside the list;
// it's appended so the select shows it instead of rendering blank.
export function buildDefaultTimeRangeItems(
value: string,
): { value: string; label: string }[] {
const items = [
{ value: DEFAULT_TIME_RANGE_UNSET, label: 'Default (Last 30 minutes)' },
...SELECTABLE_DURATIONS.map((option) => ({
value: option.value,
label: option.label,
})),
];
if (value && value !== '30m' && !items.some((item) => item.value === value)) {
items.push({ value, label: storedDurationLabel(value) });
}
return items;
}

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

@@ -0,0 +1,271 @@
import { StrictMode, type ReactNode } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { act, render, screen } from 'tests/test-utils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import DashboardContainer from '../index';
const mockUpdateTimeInterval = jest.fn();
jest.mock('store/actions', () => ({
...jest.requireActual('store/actions'),
UpdateTimeInterval: (...args: unknown[]): (() => void) => {
mockUpdateTimeInterval(...args);
return (): void => {};
},
}));
const mockSelectorMount = jest.fn();
const mockPanelMount = jest.fn();
const mockStore = { resetVariableFetch: jest.fn(), setEditContext: jest.fn() };
jest.mock('../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: typeof mockStore) => unknown): unknown =>
selector(mockStore),
}));
jest.mock('../hooks/useResolvedVariables', () => ({
useResolvedVariables: jest.fn(),
}));
jest.mock('../hooks/useSyncVariablesForSuggestions', () => ({
useSyncVariablesForSuggestions: jest.fn(),
}));
jest.mock('../hooks/useDashboardStaleCheck', () => ({
useDashboardStaleCheck: (): object => ({
showPrompt: false,
reload: jest.fn(),
dismiss: jest.fn(),
}),
}));
jest.mock('../dashboardIcons', () => ({
resolveDashboardImage: (): string => '',
}));
jest.mock('react-full-screen', () => ({
FullScreen: ({ children }: { children: ReactNode }): ReactNode => children,
useFullScreenHandle: (): object => ({ active: false }),
}));
jest.mock('../DashboardPageToolbar', () => ({
__esModule: true,
default: (): JSX.Element => {
mockSelectorMount(getUnstableCurrentSearchParams().toString());
return <div data-testid="time-selector" />;
},
}));
jest.mock('../PanelsAndSectionsLayout', () => ({
__esModule: true,
default: (): JSX.Element => {
mockPanelMount(getUnstableCurrentSearchParams().toString());
return <div data-testid="panels" />;
},
}));
jest.mock('../components/DashboardPageHeader/DashboardPageHeader', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../components/LockedIndicator/LockedIndicator', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock(
'../components/DashboardChangedDialog/DashboardChangedDialog',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock('container/TopNav/AutoRefreshV2/AutoRefreshTicker', () => ({
__esModule: true,
default: (): null => null,
}));
const PATH = '/dashboard/abc';
const refetch = jest.fn();
function dashboard(
duration = '1h',
id = 'abc',
): DashboardtypesGettableDashboardV2DTO {
return {
id,
name: id,
orgId: 'org',
source: DashboardtypesSourceDTO.user,
locked: false,
schemaVersion: 'v6',
tags: [],
spec: {
display: { name: id },
duration,
layouts: [],
panels: {},
variables: [],
},
};
}
function view(data: DashboardtypesGettableDashboardV2DTO): JSX.Element {
return (
<StrictMode>
<DashboardContainer dashboard={data} refetch={refetch} />
</StrictMode>
);
}
function mountDashboard(
search = '',
duration = '1h',
): ReturnType<typeof render> & {
update: (data: DashboardtypesGettableDashboardV2DTO) => void;
} {
const result = render(view(dashboard(duration)), undefined, {
initialRoute: `${PATH}${search}`,
});
return {
...result,
update: (data): void => result.rerender(view(data)),
};
}
describe('dashboard default time initialization', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
});
it('writes the default before the selector and panels first mount, replacing history', () => {
const historyLength = window.history.length;
mountDashboard('?variables=selected&tab=overview');
expect(screen.getByTestId('time-selector')).toBeInTheDocument();
expect(screen.getByTestId('panels')).toBeInTheDocument();
for (const mounted of [mockSelectorMount, mockPanelMount]) {
expect(mounted).toHaveBeenCalled();
for (const [search] of mounted.mock.calls) {
expect(new URLSearchParams(search).get('relativeTime')).toBe('1h');
}
}
const params = new URLSearchParams(getCurrentLocation().search);
expect(params.get('variables')).toBe('selected');
expect(params.get('tab')).toBe('overview');
expect(window.history).toHaveLength(historyLength);
});
it.each([
'?relativeTime=15m',
'?startTime=1000&endTime=2000',
'?startTime=1000',
'?endTime=2000',
])('preserves explicit URL time %s', (search) => {
mountDashboard(search);
expect(getCurrentLocation().search).toBe(search);
expect(mockSelectorMount.mock.calls[0][0]).toBe(search.slice(1));
});
it.each(['1d', JSON.stringify({ startTime: 1000, endTime: 2000 })])(
'leaves a saved route preference %s for the shared selector',
(saved) => {
localStorage.setItem(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify({ [PATH]: saved }),
);
mountDashboard();
expect(getCurrentLocation().search).toBe('');
expect(
JSON.parse(
localStorage.getItem(LOCALSTORAGE.METRICS_TIME_IN_DURATION) ?? '{}',
)[PATH],
).toBe(saved);
expect(mockSelectorMount.mock.calls[0][0]).toBe('');
},
);
it('does not use preferences from another dashboard', () => {
localStorage.setItem(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify({ '/dashboard/other': '1d' }),
);
mountDashboard();
expect(getCurrentLocation().search).toBe('?relativeTime=1h');
});
it.each([
['1h30m', '90m'],
['120m', '2h'],
['14d', '2w'],
['1y', '365d'],
['15s', '1m'],
['90s', '2m'],
['500ms', '1m'],
])('opens a %s default as %s, in whole minutes', (duration, relativeTime) => {
mountDashboard('', duration);
expect(getCurrentLocation().search).toBe(`?relativeTime=${relativeTime}`);
});
it.each(['', '0m', '0s', 'invalid'])(
'keeps the normal selector flow for unusable default %s',
(duration) => {
mountDashboard('', duration);
expect(getCurrentLocation().search).toBe('');
expect(screen.getByTestId('time-selector')).toBeInTheDocument();
},
);
it('does not reset a current selection when settings refetch', () => {
const { update } = mountDashboard();
act(() => navigate(`${PATH}?relativeTime=15m`, { replace: true }));
update(dashboard('1w'));
expect(getCurrentLocation().search).toBe('?relativeTime=15m');
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
});
it('moves a viewer still on the old default to a changed one', () => {
const { update } = mountDashboard();
update(dashboard('1d'));
expect(getCurrentLocation().search).toBe('?relativeTime=1d');
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('1d');
});
it('moves a viewer to 30 minutes when the default is cleared', () => {
const { update } = mountDashboard();
update(dashboard(''));
expect(getCurrentLocation().search).toBe('?relativeTime=30m');
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('30m');
});
it('moves a viewer off 30 minutes when a default is first set', () => {
const { update } = mountDashboard('?relativeTime=30m', '');
update(dashboard('1d'));
expect(getCurrentLocation().search).toBe('?relativeTime=1d');
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('1d');
});
it('keeps the time a viewer picked when the default changes', () => {
const { update } = mountDashboard();
localStorage.setItem(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify({ [PATH]: '1h' }),
);
update(dashboard('1d'));
expect(getCurrentLocation().search).toBe('?relativeTime=1h');
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
});
it('initializes a different dashboard before mounting its children', () => {
const { update } = mountDashboard();
mockSelectorMount.mockClear();
mockPanelMount.mockClear();
act(() => {
navigate('/dashboard/other');
update(dashboard('1w', 'other'));
});
expect(getCurrentLocation().search).toBe('?relativeTime=1w');
expect(mockSelectorMount).toHaveBeenCalled();
expect(mockPanelMount).toHaveBeenCalled();
expect(
mockSelectorMount.mock.calls.every(
([search]) => new URLSearchParams(search).get('relativeTime') === '1w',
),
).toBe(true);
expect(
mockPanelMount.mock.calls.every(
([search]) => new URLSearchParams(search).get('relativeTime') === '1w',
),
).toBe(true);
});
});

View File

@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time dispatch off redux
import { useDispatch } from 'react-redux';
import { QueryParams } from 'constants/query';
import { getDefaultOption } from 'container/TopNav/DateTimeSelectionV2/constants';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppNavigate } from 'lib/router/useAppNavigate';
import { UpdateTimeInterval } from 'store/actions';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { hasPersistedTimeForRoute } from 'utils/metricsTimeStorageUtils';
import { toPickerRelativeTime } from '../utils/dashboardDuration';
/**
* Prepares the dashboard URL before its time selector and panels mount, and
* moves a viewer still on the previous default to a changed one.
*/
export function useDashboardDefaultTime(
dashboardId: string,
duration: string | undefined,
): boolean {
const { pathname } = useAppLocation();
const navigate = useAppNavigate();
const dispatch = useDispatch();
const previousDuration = useRef(duration);
const [initialized, setInitialized] = useState<{
dashboardId: string;
pathname: string;
}>();
const isInitialized =
initialized?.dashboardId === dashboardId && initialized.pathname === pathname;
useEffect(() => {
if (isInitialized) {
return;
}
const params = getUnstableCurrentSearchParams();
const hasUrlTime = [
QueryParams.relativeTime,
QueryParams.startTime,
QueryParams.endTime,
].some((key) => params.has(key));
const relativeTime = toPickerRelativeTime(duration);
if (!hasUrlTime && !hasPersistedTimeForRoute(pathname) && relativeTime) {
params.set(QueryParams.relativeTime, relativeTime);
navigate(`${pathname}?${params.toString()}`, { replace: true });
}
setInitialized({ dashboardId, pathname });
}, [dashboardId, duration, pathname, isInitialized, navigate]);
useEffect(() => {
const previous = previousDuration.current;
previousDuration.current = duration;
if (!isInitialized || previous === duration) {
return;
}
const params = getUnstableCurrentSearchParams();
const routeDefault = getDefaultOption(pathname);
const isOnPreviousDefault =
params.get(QueryParams.relativeTime) ===
(toPickerRelativeTime(previous) ?? routeDefault) &&
!params.has(QueryParams.startTime) &&
!params.has(QueryParams.endTime) &&
!hasPersistedTimeForRoute(pathname);
if (!isOnPreviousDefault) {
return;
}
const next = toPickerRelativeTime(duration) ?? routeDefault;
params.set(QueryParams.relativeTime, next);
navigate(`${pathname}?${params.toString()}`, { replace: true });
dispatch(UpdateTimeInterval(next as Time));
}, [duration, isInitialized, pathname, navigate, dispatch]);
return isInitialized;
}

View File

@@ -14,6 +14,7 @@ import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageH
import LockedIndicator from './components/LockedIndicator/LockedIndicator';
import DashboardChangedDialog from './components/DashboardChangedDialog/DashboardChangedDialog';
import { useDashboardStaleCheck } from './hooks/useDashboardStaleCheck';
import { useDashboardDefaultTime } from './hooks/useDashboardDefaultTime';
import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
interface DashboardContainerProps {
@@ -33,8 +34,9 @@ function DashboardContainer({
dashboard,
refetch,
canEditDashboardOverride,
}: DashboardContainerProps): JSX.Element {
}: DashboardContainerProps): JSX.Element | null {
const spec = dashboard.spec;
const isTimeInitialized = useDashboardDefaultTime(dashboard.id, spec.duration);
const image = resolveDashboardImage(dashboard.image);
const name = spec.display.name;
@@ -68,6 +70,10 @@ function DashboardContainer({
const staleCheck = useDashboardStaleCheck(dashboard, refetch);
if (!isTimeInitialized) {
return null;
}
// In full screen show only the sections and panels — the header/toolbar chrome
// is hidden for a clean presentation view (exit with Esc).
return (

View File

@@ -0,0 +1,41 @@
import {
PROMETHEUS_DURATION_UNIT_MS,
prometheusDurationToMs,
} from 'utils/prometheusDuration';
const MINUTE_MS = PROMETHEUS_DURATION_UNIT_MS.m;
const PICKER_UNITS: { unit: string; minutes: number }[] = [
{ unit: 'w', minutes: 10_080 },
{ unit: 'd', minutes: 1_440 },
{ unit: 'h', minutes: 60 },
];
/**
* The time picker's relative time for a dashboard duration, or null when the
* duration is empty, zero or not a duration. The picker has no seconds, so the
* window is rounded up to whole minutes: `1h30m` opens as `90m`, `15s` as `1m`.
*/
export function toPickerRelativeTime(
duration: string | undefined,
): string | null {
const totalMs = duration ? prometheusDurationToMs(duration) : null;
if (!totalMs) {
return null;
}
const minutes = Math.ceil(totalMs / MINUTE_MS);
const largest = PICKER_UNITS.find((option) => minutes % option.minutes === 0);
return largest ? `${minutes / largest.minutes}${largest.unit}` : `${minutes}m`;
}
/** Why a hand-written `spec.duration` can't be saved, or null when it can. */
export function dashboardDurationError(duration: unknown): string | null {
if (duration === undefined || duration === null || duration === '') {
return null;
}
const totalMs =
typeof duration === 'string' ? prometheusDurationToMs(duration) : null;
if (!totalMs || totalMs % MINUTE_MS !== 0) {
return '"spec.duration" must be a whole number of minutes above zero, like 30m, 6h or 1d. The time picker has no seconds.';
}
return null;
}

View File

@@ -547,6 +547,18 @@ export const openSettings = async (
return screen.findByRole('tabpanel');
};
export const clickWhenEnabled = async (testId: string): Promise<void> => {
await waitFor(
() =>
expect(screen.getByTestId(testId)).not.toHaveAttribute(
'aria-disabled',
'true',
),
PAGE_LOAD,
);
await userEvent.click(screen.getByTestId(testId));
};
export const variableRow = (name: string): Promise<HTMLElement> =>
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);

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

@@ -7,7 +7,11 @@ import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks, openSettings } from '../DashboardPage.stories.mocks';
import {
clickWhenEnabled,
dashboardMocks,
openSettings,
} from '../DashboardPage.stories.mocks';
import DashboardPage from '../../DashboardPage';
@@ -136,7 +140,7 @@ export const PublishedToast: Story = {
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(await screen.findByTestId('public-dashboard-publish'));
await clickWhenEnabled('public-dashboard-publish');
if (args.publicWrite === 'success') {
await screen.findByText('Dashboard published successfully');
}
@@ -147,7 +151,7 @@ export const PublishedToast: Story = {
export const PublicUpdatedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(await screen.findByTestId('public-dashboard-update'));
await clickWhenEnabled('public-dashboard-update');
if (args.publicWrite === 'success') {
await screen.findByText('Public dashboard updated successfully');
}
@@ -158,9 +162,7 @@ export const PublicUpdatedToast: Story = {
export const UnpublishedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(
await screen.findByTestId('public-dashboard-unpublish'),
);
await clickWhenEnabled('public-dashboard-unpublish');
if (args.publicWrite === 'success') {
await screen.findByText('Dashboard unpublished successfully');
}

View File

@@ -17,6 +17,7 @@ import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import APIError from 'types/api/error';
import { isValidDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
import { dashboardDurationError } from 'pages/DashboardPage/DashboardContainer/utils/dashboardDuration';
import { normalizeToPostable } from './importUtils';
import JsonEditor from './JsonEditor';
@@ -46,10 +47,10 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
reader.onload = (event): void => {
try {
const target = event.target?.result;
if (!target) {
if (typeof target !== 'string') {
return;
}
const parsed = JSON.parse(target.toString());
const parsed = JSON.parse(target);
setEditorValue(JSON.stringify(parsed, null, 2));
setIsUploadError(false);
} catch {
@@ -63,7 +64,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
const handleImport = async (): Promise<void> => {
try {
setIsCreating(true);
logEvent('Dashboard List V2: Import and next clicked', {});
void logEvent('Dashboard List V2: Import and next clicked', {});
const parsed = JSON.parse(editorValue) as Record<string, unknown>;
const payload = normalizeToPostable(parsed);
// Only an icon/logo path or a base64 image is a storable `image`; reject
@@ -78,6 +79,15 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
);
return;
}
const durationError = dashboardDurationError(payload.spec?.duration);
if (durationError) {
setIsCreateError(true);
void logEvent(DashboardListEvents.ImportFailed, {
reason: 'invalid-duration',
});
toast.error(durationError);
return;
}
const response = await createDashboardV2(payload);
void logEvent(DashboardListEvents.DashboardCreated, { method: 'import' });
onClose();
@@ -129,7 +139,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
prefix={<MonitorDot size={14} />}
testId="upload-json-file"
onClick={(): void => {
logEvent('Dashboard List V2: Upload JSON file clicked', {});
void logEvent('Dashboard List V2: Upload JSON file clicked', {});
}}
>
{t('upload_json_file')}

View File

@@ -168,14 +168,12 @@ 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();

View File

@@ -1,9 +1,6 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
import {
defaultLogsSelectedColumns,
@@ -12,15 +9,12 @@ import {
import { render, screen, userEvent } from 'tests/test-utils';
import { usePreferenceContext } from '../context/PreferenceContextProvider';
import { PanelColumns, PreferenceSourcePage } from '../types';
import { PreferenceSourcePage } from '../types';
const ROUTE_LOGS = '/logs';
const ROUTE_TRACES = '/traces';
const TESTID_LOGS = 'logs';
const TESTID_TRACES = 'traces';
const ROUTE_AI = '/ai-observability';
const AI_DEFAULT_COUNT = String(TRACE_VIEW_DEFAULT_COLUMNS.length);
const TESTID_AI = 'ai';
type LogsLocalOptions = {
selectColumns?: TelemetryFieldKey[];
@@ -63,9 +57,9 @@ function Consumer({
type="button"
onClick={(): void => {
const newCols: TelemetryFieldKey[] =
sourcePage === SavedviewtypesSourceDTO.logs
? (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
: (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
sourcePage === SavedviewtypesSourceDTO.traces
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
slice.updateColumns(newCols);
}}
>
@@ -442,226 +436,4 @@ describe('PreferencesProvider integration', () => {
}
});
});
describe('AI observability traces', () => {
const renderAiConsumer = (): void => {
render(
<Consumer
sourcePage={SavedviewtypesSourceDTO.ai_observability}
testIdPrefix={TESTID_AI}
/>,
undefined,
{ initialRoute: ROUTE_AI },
);
};
it('starts with the default trace view columns', () => {
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('loads its own localStorage, not the traces list one', () => {
setLocalStorageJSON(LOCALSTORAGE.TRACES_LIST_OPTIONS, {
selectColumns: [{ name: 'traces.list.col' }],
});
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'ai.col.a' }, { name: 'ai.col.b' }],
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
});
const URL_COLUMNS = [{ name: 'ai.url.a' }, { name: 'ai.url.b' }];
const renderAtUrl = (
pathname: string,
params: Record<string, unknown>,
renderConsumer: () => void,
): void => {
const search = new URLSearchParams(
Object.entries(params).map(([key, value]) => [key, JSON.stringify(value)]),
).toString();
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
writable: true,
value: { ...originalLocation, pathname, search: `?${search}` },
});
renderConsumer();
Object.defineProperty(window, 'location', {
writable: true,
value: originalLocation,
});
};
const renderAiAtUrl = (params: Record<string, unknown>): void =>
renderAtUrl(ROUTES.AI_OBSERVABILITY_EXPLORER, params, renderAiConsumer);
it('ignores the url options on other explorers', () => {
renderAtUrl(
ROUTE_TRACES,
{ options: { selectColumns: URL_COLUMNS } },
renderAiConsumer,
);
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('loads the url columns for the panel in panelTypes', () => {
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
});
it('files the url columns under the panel in panelTypes', () => {
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.LIST,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('prefers its localStorage over the url options', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'ai.local.col' }],
});
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
});
it('keeps its url columns out of the traces slice', () => {
renderAtUrl(
ROUTES.AI_OBSERVABILITY_EXPLORER,
{
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
},
(): void => {
render(
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{ initialRoute: ROUTE_AI },
);
},
);
expect(screen.getByTestId('traces-columns-len')).toHaveTextContent(
String(defaultTraceSelectedColumns.length),
);
});
it('drops stored columns without a name or from another signal', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [
{ name: 'ai.col.a' },
{ name: 'logs.col', signal: 'logs' },
{ key: 'no.name' },
{},
'not-a-column',
],
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
});
it('ignores a stored panel that is not a list', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: 'ai.col.a',
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('falls back to the url options when every stored column is invalid', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'logs.col', signal: 'logs' }],
});
renderAiAtUrl({
options: { selectColumns: URL_COLUMNS },
panelTypes: PANEL_TYPES.TRACE,
});
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('3');
});
it('loads the columns of the panel open in the url', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.TRACE]: [{ name: 'ai.trace.a' }, { name: 'ai.trace.b' }],
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
});
render(
<Consumer
sourcePage={SavedviewtypesSourceDTO.ai_observability}
testIdPrefix={TESTID_AI}
/>,
undefined,
{
initialRoute: `${ROUTE_AI}?panelTypes=${encodeURIComponent(
JSON.stringify(PANEL_TYPES.LIST),
)}`,
},
);
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('1');
});
it('keeps each panel apart in its localStorage', () => {
setLocalStorageJSON(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS, {
[PANEL_TYPES.LIST]: [{ name: 'ai.list.col' }],
});
renderAiConsumer();
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent(
AI_DEFAULT_COUNT,
);
});
it('updateColumns writes its own localStorage key in direct mode', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderAiConsumer();
await user.click(screen.getByTestId('ai-update-columns'));
expect(screen.getByTestId('ai-columns-len')).toHaveTextContent('2');
expect(
getLocalStorageJSON<PanelColumns>(
LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS,
)?.[PANEL_TYPES.TRACE],
).toHaveLength(1);
expect(
getLocalStorageJSON<TracesLocalOptions>(LOCALSTORAGE.TRACES_LIST_OPTIONS),
).toBeNull();
});
});
});

View File

@@ -1,41 +0,0 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import getAiObservabilityUpdaterConfig from '../configs/aiObservabilityUpdaterConfig';
import { PreferenceMode } from '../types';
const columns = [{ name: 'total_tokens' }] as TelemetryFieldKey[];
describe('aiObservabilityUpdaterConfig in saved view mode', () => {
it('stores the columns under the open panel', () => {
const setSavedViewPreferences = jest.fn();
const { updateColumns } = getAiObservabilityUpdaterConfig(
jest.fn(),
setSavedViewPreferences,
PANEL_TYPES.TRACE,
);
updateColumns(columns, PreferenceMode.SAVED_VIEW);
expect(setSavedViewPreferences).toHaveBeenCalledWith({
columns,
panelType: PANEL_TYPES.TRACE,
});
});
it('stores the columns under the given panel', () => {
const setSavedViewPreferences = jest.fn();
const { updateColumns } = getAiObservabilityUpdaterConfig(
jest.fn(),
setSavedViewPreferences,
PANEL_TYPES.LIST,
);
updateColumns(columns, PreferenceMode.SAVED_VIEW, PANEL_TYPES.TRACE);
expect(setSavedViewPreferences).toHaveBeenCalledWith({
columns,
panelType: PANEL_TYPES.TRACE,
});
});
});

View File

@@ -1,11 +1,8 @@
import { useGetSavedView } from 'api/generated/services/saved-view';
import {
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TRACE_VIEW_DEFAULT_COLUMNS } from 'container/LLMObservability/Explorer/constants';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
@@ -200,64 +197,6 @@ describe('usePreferenceSync in saved view mode', () => {
);
});
it('adds trace_id to AI trace selectedFields and uses the defaults when absent', () => {
mockViews([
makeView('ai-with-fields', SavedviewtypesSourceDTO.ai_observability, {
panelType: SavedviewtypesPanelTypeDTO.trace,
selectedFields: [{ name: 'total_tokens' }],
}),
makeView('ai-without-fields', SavedviewtypesSourceDTO.ai_observability, {
panelType: SavedviewtypesPanelTypeDTO.trace,
}),
]);
const withFields = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
savedViewId: 'ai-with-fields',
}),
);
const withoutFields = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
savedViewId: 'ai-without-fields',
}),
);
expect(withFields.result.current.preferences).toStrictEqual({
columns: [{ name: 'trace_id' }, { name: 'total_tokens' }],
panelType: PANEL_TYPES.TRACE,
});
expect(withoutFields.result.current.preferences).toStrictEqual({
columns: TRACE_VIEW_DEFAULT_COLUMNS,
panelType: PANEL_TYPES.TRACE,
});
});
it('gives the open panel its defaults when the AI view was saved on another panel', () => {
mockViews([
makeView('ai-graph', SavedviewtypesSourceDTO.ai_observability, {
panelType: SavedviewtypesPanelTypeDTO.graph,
}),
]);
const { result } = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
panelType: PANEL_TYPES.TRACE,
savedViewId: 'ai-graph',
}),
);
expect(result.current.preferences).toStrictEqual({
columns: TRACE_VIEW_DEFAULT_COLUMNS,
panelType: PANEL_TYPES.TRACE,
});
});
it('uses defaults while the saved view is not loaded', () => {
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
@@ -273,21 +212,4 @@ describe('usePreferenceSync in saved view mode', () => {
ensureLogsRequiredColumns(defaultLogsSelectedColumns),
);
});
it('uses the AI trace defaults while the saved view is not loaded', () => {
mockViews([]);
const { result } = renderHook(() =>
usePreferenceSync({
mode: PreferenceMode.SAVED_VIEW,
sourcePage: SavedviewtypesSourceDTO.ai_observability,
panelType: PANEL_TYPES.TRACE,
savedViewId: 'missing',
}),
);
expect(result.current.preferences).toStrictEqual({
columns: TRACE_VIEW_DEFAULT_COLUMNS,
panelType: PANEL_TYPES.TRACE,
});
});
});

View File

@@ -114,7 +114,6 @@ describe('usePreferenceUpdater', () => {
expect(mockLogsUpdateColumns).toHaveBeenCalledWith(
newColumns,
PreferenceMode.DIRECT,
undefined,
);
expect(mockTracesUpdateColumns).not.toHaveBeenCalled();
expect(setReSync).toHaveBeenCalledWith(true);
@@ -178,7 +177,6 @@ describe('usePreferenceUpdater', () => {
expect(mockTracesUpdateColumns).toHaveBeenCalledWith(
newColumns,
PreferenceMode.DIRECT,
undefined,
);
expect(mockLogsUpdateColumns).not.toHaveBeenCalled();
expect(setReSync).toHaveBeenCalledWith(true);

View File

@@ -1,47 +1,20 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { hasPreferences, isPreferenceSourcePage } from '../utils';
import { isPreferenceSourcePage } from '../utils';
describe('isPreferenceSourcePage', () => {
it.each([
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.ai_observability,
])('is true for %s', (source) => {
expect(isPreferenceSourcePage(source)).toBe(true);
});
it.each([SavedviewtypesSourceDTO.metrics, SavedviewtypesSourceDTO.meter])(
'is false for %s',
it.each([SavedviewtypesSourceDTO.logs, SavedviewtypesSourceDTO.traces])(
'is true for %s',
(source) => {
expect(isPreferenceSourcePage(source)).toBe(false);
expect(isPreferenceSourcePage(source)).toBe(true);
},
);
});
describe('hasPreferences', () => {
it('is true for every panel of the logs and traces explorers', () => {
expect(
hasPreferences(SavedviewtypesSourceDTO.traces, PANEL_TYPES.TRACE),
).toBe(true);
expect(hasPreferences(SavedviewtypesSourceDTO.logs, PANEL_TYPES.LIST)).toBe(
true,
);
});
it('is true only for the AI observability panels that keep columns', () => {
expect(
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.TRACE),
).toBe(true);
expect(
hasPreferences(SavedviewtypesSourceDTO.ai_observability, PANEL_TYPES.LIST),
).toBe(false);
});
it('is false for source pages without a slice', () => {
expect(
hasPreferences(SavedviewtypesSourceDTO.metrics, PANEL_TYPES.LIST),
).toBe(false);
it.each([
SavedviewtypesSourceDTO.metrics,
SavedviewtypesSourceDTO.meter,
SavedviewtypesSourceDTO.ai_observability,
])('is false for %s', (source) => {
expect(isPreferenceSourcePage(source)).toBe(false);
});
});

View File

@@ -1,101 +0,0 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DEFAULT_PANEL_TYPE,
TRACE_VIEW_DEFAULT_COLUMNS,
} from 'container/LLMObservability/Explorer/constants';
import { PanelColumns } from '../types';
import { isAiObservabilityExplorerRoute } from '../utils';
/**
* Validates if a column is valid for the AI observability explorer
* Needs a name; a column from another signal would fail the trace query
*/
const isValidAiColumn = (column: unknown): column is TelemetryFieldKey => {
if (!column || typeof column !== 'object') {
return false;
}
const { name, signal } = column as { name?: unknown; signal?: unknown };
return (
typeof name === 'string' && name !== '' && (!signal || signal === 'traces')
);
};
// Keeps the valid columns of each panel; a panel left empty falls back to the
// defaults, like one that was never stored.
const toPanels = (value: unknown): PanelColumns => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.entries(value).reduce<PanelColumns>(
(panels, [panelType, columns]) => {
if (!Array.isArray(columns)) {
return panels;
}
const validColumns = columns.filter(isValidAiColumn);
return validColumns.length
? { ...panels, [panelType as PANEL_TYPES]: validColumns }
: panels;
},
{},
);
};
// Stored as { [panelType]: columns }.
export const readStoredPanels = (): PanelColumns => {
try {
return toPanels(
JSON.parse(
getLocalStorageKey(LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS) || '{}',
),
);
} catch {
return {};
}
};
// The url keeps the open panel's columns in `options.selectColumns`, and
// `panelTypes` says which panel they belong to.
const readUrlPanels = (): PanelColumns => {
if (!isAiObservabilityExplorerRoute()) {
return {};
}
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
const panelTypeParam = urlParams.get(QueryParams.panelTypes);
const panelType: PANEL_TYPES = panelTypeParam
? JSON.parse(panelTypeParam)
: DEFAULT_PANEL_TYPE;
return toPanels({ [panelType]: options?.selectColumns });
} catch {
return {};
}
};
type AiObservabilityLoaderConfig = {
local: () => { columns: TelemetryFieldKey[] };
url: () => { columns: TelemetryFieldKey[] };
default: () => { columns: TelemetryFieldKey[] };
priority: readonly ['local', 'url', 'default'];
};
// --- AI OBSERVABILITY preferences loader config ---
// Each panel keeps its own columns, so the config is built for one panel.
const getAiObservabilityLoaderConfig = (
panelType: PANEL_TYPES,
): AiObservabilityLoaderConfig => ({
local: () => ({ columns: readStoredPanels()[panelType] ?? [] }),
url: () => ({ columns: readUrlPanels()[panelType] ?? [] }),
// Only the trace panel keeps columns today.
default: () => ({
columns: panelType === PANEL_TYPES.TRACE ? TRACE_VIEW_DEFAULT_COLUMNS : [],
}),
priority: ['local', 'url', 'default'] as const,
});
export default getAiObservabilityLoaderConfig;

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