mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 16:30:42 +00:00
Compare commits
13 Commits
feat/dashb
...
nv/exec-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe45b9dcae | ||
|
|
63ee60a40c | ||
|
|
9a541c851f | ||
|
|
e0802b8274 | ||
|
|
add47f6516 | ||
|
|
2d3c4fd7d2 | ||
|
|
8f86b59822 | ||
|
|
e53d16157b | ||
|
|
83ebe26877 | ||
|
|
33d2ff917f | ||
|
|
29a393e47e | ||
|
|
c58f76f998 | ||
|
|
e39ad9e221 |
4
.github/workflows/e2eci.yaml
vendored
4
.github/workflows/e2eci.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -4377,8 +4377,7 @@ components:
|
||||
description: The dashboard title and description.
|
||||
duration:
|
||||
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.
|
||||
or 7d. Stored, but not applied by the UI.
|
||||
type: string
|
||||
layouts:
|
||||
description: The Grid layouts that place panels, at most 500. Each panel
|
||||
@@ -28288,6 +28287,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28296,9 +28301,15 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- metrics-field:list
|
||||
summary: Get rule history filter keys
|
||||
tags:
|
||||
- rules
|
||||
@@ -28466,6 +28477,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28474,9 +28491,15 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs-field:read
|
||||
- traces-field:read
|
||||
- metrics-field:read
|
||||
summary: Get rule history filter values
|
||||
tags:
|
||||
- rules
|
||||
@@ -28549,9 +28572,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get rule overall status timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28621,9 +28644,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
summary: Get rule history stats
|
||||
tags:
|
||||
- rules
|
||||
@@ -28705,6 +28728,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28713,9 +28742,19 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Get rule history timeline
|
||||
tags:
|
||||
- rules
|
||||
@@ -28780,6 +28819,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
@@ -28788,9 +28833,19 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- rule:read
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Get top contributors to rule firing
|
||||
tags:
|
||||
- rules
|
||||
|
||||
@@ -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. 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.
|
||||
* @description The default time range, as a Prometheus duration such as 1h or 7d. Stored, but not applied by the UI.
|
||||
*/
|
||||
duration?: string;
|
||||
/**
|
||||
|
||||
@@ -18,71 +18,51 @@
|
||||
right: -2px;
|
||||
width: 240px;
|
||||
|
||||
[data-format-menu='back'],
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'],
|
||||
[data-format-menu='edit-columns'] {
|
||||
--button-justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'],
|
||||
[data-format-menu='edit-columns'] {
|
||||
--button-label-min-width: 100%;
|
||||
--button-font-size: 13px;
|
||||
--button-variant-link-font-weight: 400;
|
||||
--button-size-line-height: 20px;
|
||||
}
|
||||
|
||||
[data-format-menu='back'] {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'] {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.font-size-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: unset !important;
|
||||
.horizontal-line {
|
||||
margin-inline: -12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
justify-content: space-between;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.text:hover {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.option-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,31 +82,6 @@
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
.font-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.icon {
|
||||
}
|
||||
}
|
||||
|
||||
.value:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
@@ -198,90 +153,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.max-lines-per-row-input {
|
||||
display: flex;
|
||||
|
||||
.ant-input-number-handler-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
min-width: 36px;
|
||||
width: auto;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
text-align: center;
|
||||
height: 26px;
|
||||
border-radius: 0;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-number-focused {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-input-number-input-wrap {
|
||||
input {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.periscope-btn {
|
||||
box-shadow: none;
|
||||
padding: 6px 12px;
|
||||
height: 26px;
|
||||
|
||||
border-radius: 0px 1px 1px 0px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-container {
|
||||
padding: 12px;
|
||||
|
||||
.edit-columns-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
|
||||
.edit-columns-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-container {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
LogViewMode,
|
||||
@@ -11,13 +12,15 @@ import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Minus,
|
||||
Plus,
|
||||
SlidersVertical,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import './LogsFormatOptionsMenu.styles.scss';
|
||||
|
||||
const MIN_LINES_PER_ROW = 1;
|
||||
const MAX_LINES_PER_ROW = 10;
|
||||
const FONT_SIZES = [FontSize.SMALL, FontSize.MEDIUM, FontSize.LARGE];
|
||||
|
||||
interface LogsFormatOptionsMenuProps {
|
||||
items: any;
|
||||
selectedOptionFormat: any;
|
||||
@@ -62,25 +65,11 @@ function OptionsMenu({
|
||||
onChange(key);
|
||||
};
|
||||
|
||||
const incrementMaxLinesPerRow = (): void => {
|
||||
if (maxLinesPerRow < 10) {
|
||||
setMaxLinesPerRow(maxLinesPerRow + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const decrementMaxLinesPerRow = (): void => {
|
||||
if (maxLinesPerRow > 1) {
|
||||
setMaxLinesPerRow(maxLinesPerRow - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
|
||||
if (
|
||||
maxLinesPerRow &&
|
||||
Number.isInteger(maxLinesNumber) &&
|
||||
maxLinesPerRow > 1
|
||||
) {
|
||||
setMaxLinesPerRow(maxLinesPerRow);
|
||||
const handleLinesPerRowChange = (value: number | null): void => {
|
||||
if (value !== null) {
|
||||
setMaxLinesPerRow(
|
||||
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,51 +100,36 @@ function OptionsMenu({
|
||||
{isFontSizeOptionsOpen ? (
|
||||
<div className="font-size-dropdown">
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="back"
|
||||
testId="font-size-back"
|
||||
prefix={<ChevronLeft size={14} />}
|
||||
onClick={(): void => setIsFontSizeOptionsOpen(false)}
|
||||
className="back-btn"
|
||||
type="text"
|
||||
>
|
||||
<ChevronLeft size={14} className="icon" />
|
||||
<Typography.Text className="text">Select font size</Typography.Text>
|
||||
Select font size
|
||||
</Button>
|
||||
<div className="horizontal-line" />
|
||||
<div className="content">
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.SMALL);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.SMALL}</Typography.Text>
|
||||
{fontSizeValue === FontSize.SMALL && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.MEDIUM);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.MEDIUM}</Typography.Text>
|
||||
{fontSizeValue === FontSize.MEDIUM && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.LARGE);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.LARGE}</Typography.Text>
|
||||
{fontSizeValue === FontSize.LARGE && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
{FONT_SIZES.map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="font-option"
|
||||
testId={`font-size-option-${size}`}
|
||||
onClick={(): void => setFontSizeValue(size)}
|
||||
>
|
||||
<span className="menu-row">
|
||||
{size}
|
||||
{fontSizeValue === size && <Check size={14} />}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -163,14 +137,20 @@ function OptionsMenu({
|
||||
<div className="font-size-container">
|
||||
<div className="title">Font Size</div>
|
||||
<Button
|
||||
className="value"
|
||||
type="text"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="font-size"
|
||||
testId="font-size-select"
|
||||
onClick={(): void => {
|
||||
setIsFontSizeOptionsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
<span className="menu-row">
|
||||
{fontSizeValue}
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="horizontal-line" />
|
||||
@@ -200,30 +180,20 @@ function OptionsMenu({
|
||||
<div className="horizontal-line" />
|
||||
<div className="max-lines-per-row">
|
||||
<div className="title"> max lines per row </div>
|
||||
<div className="raw-format max-lines-per-row-input">
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
<InputNumber
|
||||
controls
|
||||
mode="spinner"
|
||||
size="small"
|
||||
min={MIN_LINES_PER_ROW}
|
||||
max={MAX_LINES_PER_ROW}
|
||||
precision={0}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
aria-label="Max lines per row"
|
||||
decrementAriaLabel="Fewer lines per row"
|
||||
incrementAriaLabel="More lines per row"
|
||||
testId="max-lines-per-row"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -233,15 +203,18 @@ function OptionsMenu({
|
||||
<div className="horizontal-line" />
|
||||
<div className="edit-columns-container">
|
||||
<Button
|
||||
className="edit-columns-btn"
|
||||
type="text"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="edit-columns"
|
||||
onClick={handleEditColumns}
|
||||
data-testid="periscope-btn-edit-columns"
|
||||
testId="periscope-btn-edit-columns"
|
||||
>
|
||||
<Typography.Text className="edit-columns-text">
|
||||
<span className="menu-row">
|
||||
Edit columns
|
||||
</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -280,10 +253,15 @@ function LogsFormatOptionsMenu({
|
||||
>
|
||||
<Tooltip title="Options">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<SlidersVertical size="md" />}
|
||||
data-testid="periscope-btn-format-options"
|
||||
/>
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Options"
|
||||
testId="periscope-btn-format-options"
|
||||
>
|
||||
<SlidersVertical size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
|
||||
|
||||
@@ -107,18 +113,12 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
});
|
||||
|
||||
it('increments max-lines and calls onChange', async () => {
|
||||
const { maxLinesOnChange } = setup();
|
||||
const { maxLinesOnChange, getByTestId } = setup();
|
||||
|
||||
// Increment max lines
|
||||
const input = document.querySelector(
|
||||
'.max-lines-per-row-input input',
|
||||
) as HTMLInputElement;
|
||||
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
|
||||
const initial = Number(input.value);
|
||||
const buttons = document.querySelectorAll(
|
||||
'.max-lines-per-row-input .periscope-btn',
|
||||
);
|
||||
const incrementBtn = buttons[1] as HTMLElement;
|
||||
fireEvent.click(incrementBtn);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More lines per row' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Number(input.value)).toBe(initial + 1);
|
||||
@@ -128,28 +128,47 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps a typed max-lines value to 10', async () => {
|
||||
const { maxLinesOnChange, getByTestId } = setup();
|
||||
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, '15');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('10'));
|
||||
expect(maxLinesOnChange).toHaveBeenLastCalledWith(10);
|
||||
});
|
||||
|
||||
it('stops max-lines at 10', async () => {
|
||||
const { getByTestId } = setup();
|
||||
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
|
||||
const increment = screen.getByRole('button', { name: 'More lines per row' });
|
||||
const user = userEvent.setup();
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await user.click(increment);
|
||||
}
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('10'));
|
||||
});
|
||||
|
||||
it('changes font size to MEDIUM and calls onChange', async () => {
|
||||
const { fontSizeOnChange } = setup();
|
||||
const { fontSizeOnChange, getByTestId } = setup();
|
||||
// Open font dropdown
|
||||
const fontButton = document.querySelector(
|
||||
'.font-size-container .value',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(fontButton);
|
||||
fireEvent.click(getByTestId('font-size-select'));
|
||||
|
||||
// Choose MEDIUM
|
||||
const optionButtons = Array.from(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn'),
|
||||
);
|
||||
const mediumBtn = optionButtons[1] as HTMLElement;
|
||||
fireEvent.click(mediumBtn);
|
||||
fireEvent.click(getByTestId(`font-size-option-${FontSize.MEDIUM}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(optionButtons[1] as Element).querySelector('.icon'),
|
||||
getByTestId(`font-size-option-${FontSize.MEDIUM}`).querySelector('svg'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(`font-size-option-${FontSize.SMALL}`).querySelector('svg'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
|
||||
@@ -13,6 +13,7 @@ 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',
|
||||
|
||||
@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { isOnResourcePage } from './utils/isOnResourcePage';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveSavedViewSourceHint,
|
||||
} from './utils/resolveOpenResource';
|
||||
import { ResourceType, resourceRoute } from './utils/resourceRoute';
|
||||
import { ResourceType } from './utils/resourceRoute';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
@@ -397,7 +398,7 @@ export default function ActionsSection({
|
||||
actions,
|
||||
messageId,
|
||||
}: ActionsSectionProps): JSX.Element | null {
|
||||
const { pathname } = useAppLocation();
|
||||
const { pathname, search } = useAppLocation();
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
@@ -493,23 +494,17 @@ export default function ActionsSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
const target = getOpenResourceTarget(action);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(resourceType),
|
||||
resourceId,
|
||||
targetModule: targetModuleForResource(target.resourceType),
|
||||
resourceId: target.resourceId,
|
||||
});
|
||||
navigate(path);
|
||||
navigate(target.path);
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
@@ -607,6 +602,10 @@ export default function ActionsSection({
|
||||
// is still surfaced visually via the suffix pill below.
|
||||
|
||||
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
|
||||
const openTarget = getOpenResourceTarget(action);
|
||||
const isViewingResource =
|
||||
openTarget !== null &&
|
||||
isOnResourcePage(openTarget.path, { pathname, search });
|
||||
|
||||
let icon: JSX.Element;
|
||||
if (isSuccess) {
|
||||
@@ -619,12 +618,16 @@ export default function ActionsSection({
|
||||
|
||||
const chip = (
|
||||
<Button
|
||||
disabledTooltip="This action already ran"
|
||||
disabledTooltip={
|
||||
isViewingResource
|
||||
? "You're already viewing this page"
|
||||
: 'This action already ran'
|
||||
}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => handleClick(key, action)}
|
||||
disabled={isSuccess}
|
||||
disabled={isSuccess || isViewingResource}
|
||||
loading={isLoading}
|
||||
aria-label={action.label}
|
||||
testId={`ai-action-chip-${action.kind}`}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getCurrentLocation, navigate } from 'lib/router/navigation';
|
||||
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { AIAssistantEvents } from '../../../events';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
const mockLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const OPEN_ALERT: MessageActionDTO = {
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open alert',
|
||||
resourceType: 'alert',
|
||||
resourceId: 'rule-1',
|
||||
actionMetadataId: 'meta-1',
|
||||
};
|
||||
|
||||
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
|
||||
|
||||
describe('ActionsSection open_resource', () => {
|
||||
beforeEach(() => {
|
||||
mockLogEvent.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/alerts/edit?ruleId=rule-1&tab=1',
|
||||
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
|
||||
])('disables Open alert while that alert is open at %s', async (route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
|
||||
undefined,
|
||||
{ initialRoute: route },
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
|
||||
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
|
||||
expect(chip).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await user.click(chip);
|
||||
|
||||
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
|
||||
route,
|
||||
);
|
||||
expect(mockLogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
|
||||
'opens the alert from %s',
|
||||
async (route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
|
||||
undefined,
|
||||
{ initialRoute: route },
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
|
||||
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await user.click(chip);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
|
||||
).toBe('/alerts/edit?ruleId=rule-1'),
|
||||
);
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(
|
||||
AIAssistantEvents.ResourceOpened,
|
||||
expect.objectContaining({ resourceId: 'rule-1' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('enables Open alert again after navigating away from that alert', () => {
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
|
||||
);
|
||||
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
|
||||
act(() => navigate('/alerts/edit?ruleId=rule-2'));
|
||||
|
||||
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
type MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
} from './resolveOpenResource';
|
||||
import { resourceRoute } from './resourceRoute';
|
||||
|
||||
export interface OpenResourceTarget {
|
||||
path: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
|
||||
export function getOpenResourceTarget(
|
||||
action: MessageActionDTO,
|
||||
): OpenResourceTarget | null {
|
||||
if (
|
||||
action.kind !== MessageActionKindDTO.open_resource ||
|
||||
isSavedViewOpenAction(action)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return null;
|
||||
}
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return { path, resourceType, resourceId };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { AppLocation } from 'lib/router/types';
|
||||
|
||||
// The alert details Overview tab renders the same editor as the edit route.
|
||||
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
|
||||
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
|
||||
};
|
||||
|
||||
// Extra params on the current page (time range, view options) don't make it a different resource.
|
||||
export function isOnResourcePage(
|
||||
path: string,
|
||||
location: Pick<AppLocation, 'pathname' | 'search'>,
|
||||
): boolean {
|
||||
const [pathname, query = ''] = path.split('?');
|
||||
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
|
||||
if (!pathnames.includes(location.pathname)) {
|
||||
return false;
|
||||
}
|
||||
const current = new URLSearchParams(location.search);
|
||||
return [...new URLSearchParams(query)].every(
|
||||
([key, value]) => current.get(key) === value,
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
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;
|
||||
}
|
||||
const totalMs = prometheusDurationToMs(value);
|
||||
return totalMs !== null && totalMs >= JIRA_MIN_REOPEN_MS;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -20,23 +33,25 @@ 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: 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
/** The API rejects a duration it would print differently. */
|
||||
export const canonicalJiraReopenDuration = (value: string): string => {
|
||||
const totalMs = value && value !== '0' ? prometheusDurationToMs(value) : null;
|
||||
if (totalMs === null) {
|
||||
if (!value || value === '0' || !JIRA_DURATION_RE.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let remaining = totalMs;
|
||||
let remaining = 0;
|
||||
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
|
||||
remaining += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
|
||||
}
|
||||
if (remaining === 0) {
|
||||
return '0s';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -85,8 +86,9 @@ function Explorer(): JSX.Element {
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
const selectedView = useMemo(
|
||||
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
[searchParams, panelTypesFromUrl],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
@@ -115,8 +117,6 @@ function Explorer(): JSX.Element {
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
@@ -190,6 +190,7 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch, SetStateAction, 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,
|
||||
@@ -31,6 +32,8 @@ export type TracesTableProps = {
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
@@ -48,6 +51,8 @@ function TracesTable({
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
columnSizing,
|
||||
onColumnSizingChange,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const navigate = useAppNavigate();
|
||||
@@ -98,6 +103,8 @@ function TracesTable({
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={onColumnSizingChange}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
@@ -113,6 +120,8 @@ TracesTable.defaultProps = {
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
columnSizing: undefined,
|
||||
onColumnSizingChange: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
|
||||
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
|
||||
|
||||
// An aggregate outside the default order starts hidden, so the persisted
|
||||
// defaults are observable.
|
||||
// An aggregate that 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);
|
||||
|
||||
@@ -12,11 +12,19 @@ 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';
|
||||
@@ -40,8 +48,11 @@ import {
|
||||
TRACE_VIEW_ORDER_BY_EXTRA_FIELDS,
|
||||
} from '../constants';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { PER_PAGE_OPTIONS } from './configs';
|
||||
import { useTraceViewColumns } from './useTraceViewColumns';
|
||||
import {
|
||||
buildTraceViewColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
REQUIRED_FIELDS,
|
||||
} from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
@@ -65,14 +76,37 @@ function TracesView({
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const {
|
||||
columns,
|
||||
selectedFields,
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
canPersistColumns,
|
||||
} = useTraceViewColumns();
|
||||
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 {
|
||||
selectedTime: globalSelectedTime,
|
||||
@@ -174,24 +208,6 @@ 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}>
|
||||
@@ -215,11 +231,11 @@ function TracesView({
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
config={fieldsSelectorConfig}
|
||||
config={config}
|
||||
fieldKeysConfig={TRACE_VIEW_FIELD_KEYS}
|
||||
builderQueryType={TRACE_VIEW_BUILDER_QUERY_TYPE}
|
||||
extraFields={TRACE_VIEW_COLUMN_EXTRA_FIELDS}
|
||||
requiredFields={requiredFields}
|
||||
requiredFields={REQUIRED_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,15 +243,13 @@ function TracesView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={
|
||||
canPersistColumns
|
||||
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
|
||||
: undefined
|
||||
}
|
||||
respectColumnOrder
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
columnSizing={columnSizing}
|
||||
onColumnSizingChange={handleColumnSizingChange}
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading || isColumnsLoading}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,7 @@ import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
const PANEL_COLUMNS_KEY = LOCALSTORAGE.AI_OBSERVABILITY_PANEL_COLUMNS;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
@@ -84,9 +83,9 @@ const mockFieldKeysFailure = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
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 renderTracesView = (): ReturnType<typeof render> =>
|
||||
@@ -121,16 +120,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('seeds the persisted defaults once the field keys arrive', async () => {
|
||||
it('shows the default columns once the field keys arrive, storing nothing', async () => {
|
||||
mockFieldKeys(['llm_call_count', 'tool_call_count']);
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(storedOptions()).toBeNull();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -140,20 +139,19 @@ describe('TracesView column persistence', () => {
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
expect(storedOptions()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the column picker when the field keys fail', async () => {
|
||||
it('keeps the column picker when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the display-only columns when the field keys fail', async () => {
|
||||
it('renders the default columns when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
@@ -162,22 +160,27 @@ describe('TracesView column persistence', () => {
|
||||
expect(screen.getByText('root_span_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.getByText('input')).toBeInTheDocument();
|
||||
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves an existing selection untouched while the field keys fail', async () => {
|
||||
it('shows a stored selection, in its order, while the field keys fail', async () => {
|
||||
const existing = {
|
||||
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
|
||||
columnOrder: ['trace_id', 'resource:service.name'],
|
||||
columnSizing: {},
|
||||
[PANEL_TYPES.TRACE]: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'trace_id' },
|
||||
],
|
||||
};
|
||||
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
|
||||
localStorage.setItem(PANEL_COLUMNS_KEY, JSON.stringify(existing));
|
||||
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(persistedState()).toStrictEqual(existing);
|
||||
const headers = screen
|
||||
.getAllByRole('columnheader')
|
||||
.map((header) => header.textContent);
|
||||
expect(headers).toStrictEqual(['service.name', 'trace_id']);
|
||||
expect(storedOptions()).toStrictEqual(existing);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/* 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,52 +3,17 @@ 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];
|
||||
|
||||
/** 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 REQUIRED_FIELDS: readonly string[] = [TRACE_ID_COLUMN_ID];
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ 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',
|
||||
@@ -57,6 +60,81 @@ 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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.logsTable {
|
||||
--tanstack-table-row-height: 30px;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
@@ -19,7 +18,6 @@
|
||||
thead {
|
||||
tr {
|
||||
th {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: white !important;
|
||||
|
||||
@@ -37,7 +35,6 @@
|
||||
}
|
||||
|
||||
td {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -57,7 +54,6 @@
|
||||
tbody {
|
||||
tr {
|
||||
td {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px !important;
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
isTraceToLogsQuery,
|
||||
} from './utils';
|
||||
|
||||
import styles from './LogsExplorerList.module.scss';
|
||||
import './LogsExplorerList.style.scss';
|
||||
|
||||
function Footer(): JSX.Element {
|
||||
@@ -202,6 +203,7 @@ function LogsExplorerList({
|
||||
return (
|
||||
<TanStackTable<ILog>
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
className={styles.logsTable}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.logs-explorer-views-container {
|
||||
margin-bottom: 24px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('useOptionsMenu', () => {
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
|
||||
mockUseUrlQueryData.mockReturnValue({
|
||||
@@ -236,6 +237,7 @@ describe('useOptionsMenu', () => {
|
||||
mockUsePreferenceContext.mockReturnValue({
|
||||
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
|
||||
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
});
|
||||
|
||||
@@ -357,6 +359,7 @@ describe('useOptionsMenu', () => {
|
||||
},
|
||||
),
|
||||
[SavedviewtypesSourceDTO.logs]: slice([], {}),
|
||||
[SavedviewtypesSourceDTO.ai_observability]: slice([], {}),
|
||||
} satisfies PreferenceContextValue);
|
||||
|
||||
const { result } = renderHook(
|
||||
|
||||
@@ -21,6 +21,7 @@ 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[]> = {
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import {
|
||||
hasPreferences,
|
||||
isPreferenceSourcePage,
|
||||
} from 'providers/preferences/utils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
@@ -20,9 +23,12 @@ export function useActiveSavedView(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
|
||||
const hasOptions = hasPreferences(source, viewPanelType);
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
sourcePage: isPreferenceSourcePage(source)
|
||||
? source
|
||||
: SavedviewtypesSourceDTO.logs,
|
||||
});
|
||||
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
@@ -61,16 +67,18 @@ export function useActiveSavedView(
|
||||
return hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
panelType: viewPanelType,
|
||||
options: hasOptions ? options : undefined,
|
||||
sourcePage: source,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isError,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
viewPanelType,
|
||||
hasOptions,
|
||||
source,
|
||||
options,
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
|
||||
import { isPreferenceSourcePage } from 'providers/preferences/utils';
|
||||
import {
|
||||
hasPreferences,
|
||||
isPreferenceSourcePage,
|
||||
} from 'providers/preferences/utils';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
@@ -59,9 +62,12 @@ export function useSavedViewActions(
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const logSavedViewEvent = useSavedViewLogEvent(source);
|
||||
|
||||
const hasOptions = isPreferenceSourcePage(source);
|
||||
const viewPanelType = panelType ?? PANEL_TYPES.LIST;
|
||||
const hasOptions = hasPreferences(source, viewPanelType);
|
||||
const { options } = useOptionsMenu({
|
||||
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
|
||||
sourcePage: isPreferenceSourcePage(source)
|
||||
? source
|
||||
: SavedviewtypesSourceDTO.logs,
|
||||
});
|
||||
const preferenceSlice = usePreferenceSlice(source);
|
||||
|
||||
@@ -79,12 +85,12 @@ export function useSavedViewActions(
|
||||
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
|
||||
toSavedViewSpec({
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
panelType: viewPanelType,
|
||||
displayName,
|
||||
options: hasOptions ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, panelType, hasOptions, options],
|
||||
[stagedQuery, currentQuery, viewPanelType, hasOptions, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
@@ -101,11 +107,11 @@ export function useSavedViewActions(
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
if (preferenceSlice) {
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
const { columns, formatting, panelType } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
source,
|
||||
);
|
||||
preferenceSlice.updateColumns(columns);
|
||||
preferenceSlice.updateColumns(columns, panelType);
|
||||
if (formatting) {
|
||||
preferenceSlice.updateFormatting(formatting);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface HasUnsavedViewChangesArgs {
|
||||
stagedQuery: Query | null;
|
||||
panelType: PANEL_TYPES;
|
||||
options?: SavedViewOptions;
|
||||
sourcePage: SavedviewtypesSourceDTO;
|
||||
}
|
||||
|
||||
export interface UseLastUsedViewResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
@@ -120,6 +121,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: [columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -139,6 +141,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, selectColumns: defaultTraceSelectedColumns },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -163,6 +166,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -174,6 +178,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -193,6 +198,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
options: {
|
||||
selectColumns: defaultLogsSelectedColumns,
|
||||
format: 'table',
|
||||
@@ -236,6 +242,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: olderView,
|
||||
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -283,6 +290,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: storedView,
|
||||
stagedQuery: stagedWithTag,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
sourcePage: SavedviewtypesSourceDTO.metrics,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -322,6 +330,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: noOrder,
|
||||
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -337,6 +346,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: noOrder,
|
||||
stagedQuery: sorted,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -346,6 +356,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: saved,
|
||||
stagedQuery: stagedFrom(saved, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -362,6 +373,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view: legacy,
|
||||
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.logs,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -373,6 +385,7 @@ describe('hasUnsavedViewChanges', () => {
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
sourcePage: SavedviewtypesSourceDTO.traces,
|
||||
options: { ...options, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -3,12 +3,18 @@ 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(
|
||||
@@ -47,5 +53,18 @@ 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: [] };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SavedviewtypesSourceDTO,
|
||||
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> = {
|
||||
@@ -51,6 +52,7 @@ export function hasUnsavedViewChanges({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
options,
|
||||
sourcePage,
|
||||
}: HasUnsavedViewChangesArgs): boolean {
|
||||
if (!stagedQuery) {
|
||||
return false;
|
||||
@@ -77,10 +79,9 @@ export function hasUnsavedViewChanges({
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
toSavedViewSource(dataSource),
|
||||
sourcePage,
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
@@ -88,7 +89,7 @@ export function hasUnsavedViewChanges({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dataSource !== DataSource.LOGS || !formatting) {
|
||||
if (sourcePage !== SavedviewtypesSourceDTO.logs || !formatting) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,8 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// 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.
|
||||
// The AI assistant names a view's source by DataSource (the signal), the api
|
||||
// keys views by source page.
|
||||
// TODO: remove in a follow-up once callers pass the source page directly.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import { SlidersVertical } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import { 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,15 +17,23 @@ function TraceExplorerControls({
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t('options_menu.options');
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<>
|
||||
<Tooltip title={label}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={label}
|
||||
testId="periscope-btn-options-traces"
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
<SlidersVertical size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
@@ -34,7 +42,7 @@ function TraceExplorerControls({
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,32 @@
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 40px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.orderBy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orderByLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.drawerScope {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.trace-explorer-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-by-select {
|
||||
width: 100px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ListView from './index';
|
||||
|
||||
@@ -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.getByText(/options_menu.options|options/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('periscope-btn-options-traces')).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
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
expect(optionsButton).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
|
||||
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows interaction with controls in empty state', async () => {
|
||||
@@ -164,8 +164,8 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getAllByRole('combobox').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
expect(optionsButton).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('periscope-btn-options-traces'));
|
||||
await expect(screen.findByText('Edit columns')).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,6 @@ 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 {
|
||||
@@ -176,10 +174,10 @@ function ListView({
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className={styles.controls}>
|
||||
{headerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
<div className={styles.orderBy}>
|
||||
<div className={styles.orderByLabel}>
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
}
|
||||
|
||||
.tracesTable {
|
||||
--tanstack-table-row-height: 30px;
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l3-background-60);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ function TracesView({
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<div className={styles.controls}>
|
||||
{headerActions}
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.listLightMode {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-400);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -22,11 +28,14 @@ const LIST_VERTICAL_PADDING = 16;
|
||||
interface TooltipListProps {
|
||||
id: string;
|
||||
content: TooltipContentItem[];
|
||||
/** Makes each row a button. */
|
||||
onItemClick?: (index: number, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
export default function TooltipList({
|
||||
id,
|
||||
content,
|
||||
onItemClick,
|
||||
}: TooltipListProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isScrollEventTriggered = useRef(false);
|
||||
@@ -62,9 +71,23 @@ export default function TooltipList({
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
|
||||
)}
|
||||
itemContent={(index, item): JSX.Element => {
|
||||
const row = (
|
||||
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
|
||||
);
|
||||
return onItemClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className={Styles.itemButton}
|
||||
data-testid="uplot-tooltip-item-button"
|
||||
onClick={(event): void => onItemClick(index, event)}
|
||||
>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
row
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,34 +26,7 @@
|
||||
}
|
||||
|
||||
.pieChartTooltip {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pieChartTooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pieChartIndicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pieChartTooltipValue {
|
||||
font-weight: bold;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// width/height are set inline from the computed chart dimensions; border-box
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie as VisxPie } from '@visx/shape';
|
||||
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { useTooltipInPortal } from '@visx/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -14,14 +14,15 @@ import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import PieArc from 'lib/visualization/charts/Pie/PieArc';
|
||||
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
|
||||
import PieArc from 'lib/visualization/charts/Pie/components/PieArc/PieArc';
|
||||
import { groupPieSlices } from 'lib/visualization/charts/Pie/utils/groupPieSlices';
|
||||
import PieCenterLabel from 'lib/visualization/charts/Pie/components/PieCenterLabel/PieCenterLabel';
|
||||
import PieTooltipContent from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltipContent';
|
||||
import styles from 'lib/visualization/charts/Pie/Pie.module.scss';
|
||||
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
|
||||
import {
|
||||
getDonutGeometry,
|
||||
getFillColor,
|
||||
} from 'lib/visualization/charts/Pie/utils';
|
||||
import { usePieTooltip } from 'lib/visualization/charts/Pie/hooks/usePieTooltip';
|
||||
import { getFillColor } from 'lib/visualization/charts/Pie/utils/colors';
|
||||
import { getDonutGeometry } from 'lib/visualization/charts/Pie/utils/geometry';
|
||||
import { compareSlices } from 'lib/visualization/charts/Pie/utils/slices';
|
||||
|
||||
/**
|
||||
* Donut chart rendered with @visx. Splits its area into chart + legend with the
|
||||
@@ -56,20 +57,29 @@ export default function Pie({
|
||||
[data, hiddenKeys],
|
||||
);
|
||||
|
||||
// Hidden slices drop out so the remaining arcs + centre total recompute.
|
||||
// Hidden slices drop out before grouping, so Other and the centre total recompute.
|
||||
const visibleData = useMemo(
|
||||
() => data.filter((slice) => !hiddenKeys.has(slice.label)),
|
||||
[data, hiddenKeys],
|
||||
() =>
|
||||
groupPieSlices(
|
||||
data.filter((slice) => !hiddenKeys.has(slice.label)),
|
||||
isDarkMode,
|
||||
),
|
||||
[data, hiddenKeys, isDarkMode],
|
||||
);
|
||||
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipLeft,
|
||||
tooltipTop,
|
||||
tooltipData,
|
||||
hideTooltip,
|
||||
showTooltip,
|
||||
} = useTooltip<PieTooltipData>();
|
||||
// A series folded into Other highlights the Other arc.
|
||||
const drawnColorByLabel = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
visibleData.flatMap((slice) =>
|
||||
(slice.groupedMembers ?? [slice]).map((entry): [string, string] => [
|
||||
entry.label,
|
||||
slice.color,
|
||||
]),
|
||||
),
|
||||
),
|
||||
[visibleData],
|
||||
);
|
||||
|
||||
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
||||
scroll: true,
|
||||
@@ -112,34 +122,64 @@ export default function Pie({
|
||||
[visibleData],
|
||||
);
|
||||
|
||||
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
|
||||
const activeColor =
|
||||
focusedSeriesIndex !== null ? data[focusedSeriesIndex].color : null;
|
||||
|
||||
const handleSliceEnter = useCallback(
|
||||
(slice: PieSlice, centroidX: number, centroidY: number): void => {
|
||||
showTooltip({
|
||||
tooltipData: {
|
||||
label: slice.label,
|
||||
value: getYAxisFormattedValue(
|
||||
slice.value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
),
|
||||
color: slice.color,
|
||||
},
|
||||
tooltipTop: centroidY + height / 2,
|
||||
tooltipLeft: centroidX + width / 2,
|
||||
});
|
||||
setFocusedKey(slice.label);
|
||||
},
|
||||
[showTooltip, setFocusedKey, yAxisUnit, decimalPrecision, height, width],
|
||||
const formatValue = useCallback(
|
||||
(value: number): string =>
|
||||
getYAxisFormattedValue(
|
||||
value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
),
|
||||
[yAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const handleSliceLeave = useCallback((): void => {
|
||||
hideTooltip();
|
||||
setFocusedKey(null);
|
||||
}, [hideTooltip, setFocusedKey]);
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipTop,
|
||||
tooltipLeft,
|
||||
tooltipData,
|
||||
isPinned,
|
||||
unpin,
|
||||
handleSliceHover,
|
||||
handleSliceLeave,
|
||||
handleSliceClick,
|
||||
handleMemberClick,
|
||||
} = usePieTooltip({
|
||||
slices: visibleData,
|
||||
chartRef: wrapperRef,
|
||||
formatValue,
|
||||
setFocusedKey,
|
||||
onSliceClick,
|
||||
});
|
||||
|
||||
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
|
||||
const hoveredColor = tooltipOpen ? tooltipData?.slice.color : undefined;
|
||||
const activeColor =
|
||||
hoveredColor ??
|
||||
(focusedSeriesIndex !== null
|
||||
? (drawnColorByLabel.get(labels[focusedSeriesIndex]) ?? null)
|
||||
: null);
|
||||
|
||||
// The tooltip re-renders Pie on every mouse move; a stable element lets React skip the legend.
|
||||
const legend = useMemo(
|
||||
() => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showLegendSearch}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[
|
||||
legendItems,
|
||||
position,
|
||||
averageLegendWidth,
|
||||
showLegendSearch,
|
||||
focusedSeriesIndex,
|
||||
onLegendAction,
|
||||
],
|
||||
);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
@@ -169,6 +209,7 @@ export default function Pie({
|
||||
<VisxPie
|
||||
data={visibleData}
|
||||
pieValue={(slice: PieSlice): number => slice.value}
|
||||
pieSort={compareSlices}
|
||||
outerRadius={radius}
|
||||
innerRadius={innerRadius}
|
||||
padAngle={0.01}
|
||||
@@ -193,9 +234,9 @@ export default function Pie({
|
||||
decimalPrecision={decimalPrecision}
|
||||
labelColor={labelColor}
|
||||
fill={getFillColor(arc.data.color, activeColor)}
|
||||
onEnter={handleSliceEnter}
|
||||
onHover={handleSliceHover}
|
||||
onLeave={handleSliceLeave}
|
||||
onClick={onSliceClick}
|
||||
onClick={handleSliceClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -216,19 +257,19 @@ export default function Pie({
|
||||
top={tooltipTop}
|
||||
left={tooltipLeft}
|
||||
className={styles.pieChartTooltip}
|
||||
style={{
|
||||
...defaultStyles,
|
||||
color: labelColor,
|
||||
}}
|
||||
unstyled
|
||||
applyPositionStyle
|
||||
style={{ pointerEvents: isPinned ? 'auto' : 'none' }}
|
||||
data-pie-tooltip=""
|
||||
>
|
||||
<div
|
||||
className={styles.pieChartIndicator}
|
||||
style={{ background: tooltipData.color }}
|
||||
<PieTooltipContent
|
||||
id={id ?? ''}
|
||||
data={tooltipData}
|
||||
isPinned={isPinned}
|
||||
canDrilldown={!!onSliceClick}
|
||||
onMemberClick={handleMemberClick}
|
||||
onUnpin={unpin}
|
||||
/>
|
||||
<div className={styles.pieChartTooltipContent}>
|
||||
<span>{tooltipData.label}</span>
|
||||
<span className={styles.pieChartTooltipValue}>{tooltipData.value}</span>
|
||||
</div>
|
||||
</TooltipInPortal>
|
||||
)}
|
||||
</div>
|
||||
@@ -241,14 +282,7 @@ export default function Pie({
|
||||
height: legendHeight,
|
||||
}}
|
||||
>
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showLegendSearch}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
{legend}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
|
||||
import Pie from 'lib/visualization/charts/Pie/Pie';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
|
||||
@@ -14,22 +15,43 @@ jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getYAxisFormattedValue: jest.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
// VirtuosoGrid only renders a window in jsdom; render every item so we can
|
||||
// assert on legend entries.
|
||||
let mockLegendRenders = 0;
|
||||
|
||||
// Virtuoso only renders a window in jsdom; render every item so we can
|
||||
// assert on legend entries and tooltip rows.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: ({
|
||||
data,
|
||||
itemContent,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
data: unknown[];
|
||||
itemContent: (index: number, item: unknown) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid={testId}>
|
||||
{data.map((item, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid">
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}): JSX.Element => {
|
||||
mockLegendRenders += 1;
|
||||
return (
|
||||
<div data-testid="virtuoso-grid">
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const DATA: PieSlice[] = [
|
||||
@@ -40,10 +62,16 @@ const DATA: PieSlice[] = [
|
||||
|
||||
function renderPie(
|
||||
props: Partial<React.ComponentProps<typeof Pie>> = {},
|
||||
): void {
|
||||
render(<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />);
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />,
|
||||
);
|
||||
}
|
||||
|
||||
const mockSliceClick = (): jest.MockedFunction<
|
||||
NonNullable<PieChartProps['onSliceClick']>
|
||||
> => jest.fn();
|
||||
|
||||
describe('Pie', () => {
|
||||
it('renders the "No data" state for empty data', () => {
|
||||
render(<Pie data={[]} isDarkMode={false} data-testid="pie" />);
|
||||
@@ -124,4 +152,224 @@ describe('Pie', () => {
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hints at drill-down on a plain slice only when clickable', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { unmount } = renderPie({ onSliceClick: mockSliceClick() });
|
||||
const arc = (): Element =>
|
||||
screen.getByTestId('pie').querySelectorAll('svg g g')[0];
|
||||
await user.hover(arc());
|
||||
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderPie();
|
||||
await user.hover(arc());
|
||||
expect(screen.queryByTestId('pie-tooltip-footer')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not re-render the legend while the tooltip follows the cursor', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderPie();
|
||||
const arc = screen.getByTestId('pie').querySelectorAll('svg g g')[0];
|
||||
await user.hover(arc);
|
||||
const rendersAfterEnter = mockLegendRenders;
|
||||
|
||||
// user-event leaves clientX/Y at 0 in jsdom, so cursor moves go through fireEvent.
|
||||
fireEvent.mouseMove(arc, { clientX: 20, clientY: 20 });
|
||||
fireEvent.mouseMove(arc, { clientX: 30, clientY: 30 });
|
||||
|
||||
expect(mockLegendRenders).toBe(rendersAfterEnter);
|
||||
});
|
||||
|
||||
describe('grouped slice', () => {
|
||||
// Each member is under 1% of the 2078 total, so the 12 fold into Other.
|
||||
const members: PieSlice[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `svc-${index}`,
|
||||
value: 12 - index,
|
||||
color: '#123456',
|
||||
}));
|
||||
const GROUPED: PieSlice[] = [
|
||||
{ label: 'frontend', value: 1000, color: '#aa0000' },
|
||||
{ label: 'cart', value: 600, color: '#00aa00' },
|
||||
{ label: 'checkout', value: 400, color: '#0000aa' },
|
||||
...members,
|
||||
];
|
||||
const arcs = (testId = 'pie'): NodeListOf<SVGPathElement> =>
|
||||
(
|
||||
screen.getByTestId(testId).querySelector('svg') as SVGElement
|
||||
).querySelectorAll('path');
|
||||
const arcGroups = (testId = 'pie'): NodeListOf<SVGGElement> =>
|
||||
(
|
||||
screen.getByTestId(testId).querySelector('svg') as SVGElement
|
||||
).querySelectorAll('g g');
|
||||
const tooltipList = (): HTMLElement =>
|
||||
screen.getByTestId('uplot-tooltip-list');
|
||||
const tooltip = (): HTMLElement =>
|
||||
document.querySelector('[data-pie-tooltip]') as HTMLElement;
|
||||
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
});
|
||||
|
||||
const hoverOther = async (testId = 'pie'): Promise<SVGGElement> => {
|
||||
const otherArc = arcGroups(testId)[3];
|
||||
await user.hover(otherArc);
|
||||
return otherArc;
|
||||
};
|
||||
|
||||
it('draws one Other arc but lists every series in the legend', () => {
|
||||
renderPie({ data: GROUPED });
|
||||
|
||||
expect(arcs()).toHaveLength(4);
|
||||
expect(screen.getByText('Other (12)')).toBeInTheDocument();
|
||||
const legend = screen.getByTestId('virtuoso-grid');
|
||||
expect(within(legend).getByText('svc-11')).toBeInTheDocument();
|
||||
expect(within(legend).queryByText(/^Other/)).toBeNull();
|
||||
});
|
||||
|
||||
it('highlights the Other arc when a series inside it is hovered in the legend', async () => {
|
||||
renderPie({ data: GROUPED });
|
||||
|
||||
await user.hover(screen.getByTestId('legend-item-5'));
|
||||
|
||||
const fills = [...arcs()].map((arc) => arc.getAttribute('fill'));
|
||||
expect(fills[0]).toBe('rgba(170, 0, 0, 0.4)');
|
||||
expect(fills[3]).not.toMatch(/^rgba/);
|
||||
});
|
||||
|
||||
it('highlights a real series named Other, not the grouped slice', async () => {
|
||||
renderPie({
|
||||
data: [
|
||||
...GROUPED.slice(0, 3),
|
||||
{ label: 'Other', value: 500, color: '#00aaaa' },
|
||||
...members,
|
||||
],
|
||||
});
|
||||
|
||||
await user.hover(screen.getByTestId('legend-item-3'));
|
||||
|
||||
const fills = [...arcs()].map((arc) => arc.getAttribute('fill'));
|
||||
expect(fills[3]).toBe('#00aaaa');
|
||||
expect(fills[fills.length - 1]).toMatch(/^rgba/);
|
||||
});
|
||||
|
||||
it('regroups Other from the series left showing', async () => {
|
||||
renderPie({ data: GROUPED });
|
||||
|
||||
// Isolate one member, then add a second back: Other can't hold just one.
|
||||
await user.click(screen.getByTestId('legend-item-3'));
|
||||
expect(arcs()).toHaveLength(1);
|
||||
await user.click(screen.getByTestId('legend-item-0'));
|
||||
await user.click(screen.getByTestId('legend-item-4'));
|
||||
|
||||
expect(arcs()).toHaveLength(3);
|
||||
expect(screen.queryByText(/^Other/)).toBeNull();
|
||||
});
|
||||
|
||||
it('lists every member in the hover tooltip, not yet clickable', async () => {
|
||||
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
|
||||
|
||||
await hoverOther();
|
||||
|
||||
expect(tooltipList().children).toHaveLength(12);
|
||||
expect(within(tooltipList()).getByText('svc-11')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Click to pin, then pick a series'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('uplot-tooltip-item-button')).toBeNull();
|
||||
expect(screen.queryByTestId('uplot-tooltip-status')).toBeNull();
|
||||
});
|
||||
|
||||
it('follows the cursor until pinned', async () => {
|
||||
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
|
||||
const otherArc = await hoverOther();
|
||||
|
||||
// user-event leaves clientX/Y at 0 in jsdom, so cursor moves go through fireEvent.
|
||||
fireEvent.mouseMove(otherArc, { clientX: 100, clientY: 50 });
|
||||
const first = tooltip().getAttribute('style');
|
||||
fireEvent.mouseMove(otherArc, { clientX: 140, clientY: 90 });
|
||||
const moved = tooltip().getAttribute('style');
|
||||
expect(moved).not.toBe(first);
|
||||
|
||||
await user.click(otherArc);
|
||||
fireEvent.mouseMove(otherArc, { clientX: 10, clientY: 10 });
|
||||
expect(tooltip().getAttribute('style')).toBe(moved);
|
||||
});
|
||||
|
||||
it('pins on click, then drills into the clicked member', async () => {
|
||||
const onSliceClick = mockSliceClick();
|
||||
renderPie({ data: GROUPED, onSliceClick });
|
||||
|
||||
const otherArc = await hoverOther();
|
||||
await user.click(otherArc);
|
||||
await user.unhover(otherArc);
|
||||
|
||||
expect(onSliceClick).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 series')).toBeInTheDocument();
|
||||
expect(screen.getByText('Click a series to drilldown')).toBeInTheDocument();
|
||||
|
||||
jest
|
||||
.spyOn(tooltip(), 'getBoundingClientRect')
|
||||
.mockReturnValue({ left: 40, top: 70 } as DOMRect);
|
||||
await user.click(within(tooltipList()).getByText('svc-3'));
|
||||
|
||||
// The drill-down menu opens where the pinned tooltip was, not at the row.
|
||||
expect(onSliceClick).toHaveBeenCalledWith(members[3], { x: 40, y: 70 });
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('unpins on Escape, a click outside and the Unpin button', async () => {
|
||||
renderPie({ data: GROUPED, onSliceClick: mockSliceClick() });
|
||||
|
||||
await user.click(await hoverOther());
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
|
||||
await user.click(await hoverOther());
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
|
||||
await user.click(await hoverOther());
|
||||
await user.click(screen.getByTestId('pie-tooltip-unpin'));
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('unpins when a click lands on another pie', async () => {
|
||||
render(
|
||||
<>
|
||||
<Pie data={GROUPED} isDarkMode={false} data-testid="pie" />
|
||||
<Pie data={DATA} isDarkMode={false} data-testid="other-pie" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(await hoverOther());
|
||||
expect(tooltipList()).toBeInTheDocument();
|
||||
|
||||
await user.click(arcGroups('other-pie')[0]);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the pinned tooltip when new data arrives', async () => {
|
||||
const { rerender } = renderPie({ data: GROUPED });
|
||||
|
||||
await user.click(await hoverOther());
|
||||
expect(tooltipList()).toBeInTheDocument();
|
||||
|
||||
rerender(<Pie data={[...GROUPED]} isDarkMode={false} data-testid="pie" />);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('pins without drill-down targets when drill-down is off', async () => {
|
||||
renderPie({ data: GROUPED });
|
||||
|
||||
await user.click(await hoverOther());
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-item-button')).toBeNull();
|
||||
expect(screen.getByTestId('pie-tooltip-unpin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
|
||||
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils';
|
||||
import { getArcGeometry } from 'lib/visualization/charts/Pie/utils/geometry';
|
||||
import { getSliceDisplayLabel } from 'lib/visualization/charts/Pie/utils/slices';
|
||||
|
||||
// Slices below this share of the total don't get a leader label (too cramped).
|
||||
const MIN_LABEL_SHARE = 0.03;
|
||||
@@ -14,7 +15,7 @@ interface PieArcProps {
|
||||
slice: PieSlice;
|
||||
/** SVG path `d` for the arc, from the visx pie generator. */
|
||||
arcPath: string;
|
||||
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
|
||||
/** Arc centroid `[x, y]`, used to anchor the leader line. */
|
||||
centroid: [number, number];
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
@@ -26,7 +27,8 @@ interface PieArcProps {
|
||||
labelColor: string;
|
||||
/** Resolved fill (already dimmed if another slice is active). */
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
/** Fired on enter and on every move, so the tooltip can follow the cursor. */
|
||||
onHover: (slice: PieSlice, event: ReactMouseEvent<SVGGElement>) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
}
|
||||
@@ -34,7 +36,7 @@ interface PieArcProps {
|
||||
/**
|
||||
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
|
||||
* out to an external label + value. Pure presentation — interaction is
|
||||
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
|
||||
* delegated to the `onHover`/`onLeave`/`onClick` callbacks.
|
||||
*/
|
||||
export default function PieArc({
|
||||
slice,
|
||||
@@ -48,11 +50,12 @@ export default function PieArc({
|
||||
decimalPrecision,
|
||||
labelColor,
|
||||
fill,
|
||||
onEnter,
|
||||
onHover,
|
||||
onLeave,
|
||||
onClick,
|
||||
}: PieArcProps): JSX.Element {
|
||||
const { label, value } = slice;
|
||||
const { value } = slice;
|
||||
const label = getSliceDisplayLabel(slice);
|
||||
const [centroidX, centroidY] = centroid;
|
||||
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
|
||||
startAngle,
|
||||
@@ -71,7 +74,9 @@ export default function PieArc({
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
data-pie-arc=""
|
||||
onMouseEnter={(event): void => onHover(slice, event)}
|
||||
onMouseMove={(event): void => onHover(slice, event)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
import PieArc from 'lib/visualization/charts/Pie/PieArc';
|
||||
import PieArc from 'lib/visualization/charts/Pie/components/PieArc/PieArc';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
// Echo the raw value so assertions are deterministic.
|
||||
@@ -11,12 +11,12 @@ jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
|
||||
|
||||
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
|
||||
onEnter: jest.Mock;
|
||||
onHover: jest.Mock;
|
||||
onLeave: jest.Mock;
|
||||
onClick: jest.Mock;
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const onEnter = jest.fn();
|
||||
const onHover = jest.fn();
|
||||
const onLeave = jest.fn();
|
||||
const onClick = jest.fn();
|
||||
const { container } = render(
|
||||
@@ -31,14 +31,14 @@ function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
|
||||
totalValue={100}
|
||||
labelColor="#fff"
|
||||
fill="#f00"
|
||||
onEnter={onEnter}
|
||||
onHover={onHover}
|
||||
onLeave={onLeave}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
return { onEnter, onLeave, onClick, container };
|
||||
return { onHover, onLeave, onClick, container };
|
||||
}
|
||||
|
||||
describe('PieArc', () => {
|
||||
@@ -69,12 +69,14 @@ describe('PieArc', () => {
|
||||
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
|
||||
const { onEnter, onLeave, onClick, container } = renderArc();
|
||||
it('fires onHover with the slice on enter and move, and onLeave / onClick', () => {
|
||||
const { onHover, onLeave, onClick, container } = renderArc();
|
||||
const g = container.querySelector('g') as SVGGElement;
|
||||
|
||||
fireEvent.mouseEnter(g);
|
||||
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
|
||||
fireEvent.mouseMove(g);
|
||||
expect(onHover).toHaveBeenCalledTimes(2);
|
||||
expect(onHover).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
|
||||
fireEvent.mouseLeave(g);
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils';
|
||||
import { getScaledFontSize } from 'lib/visualization/charts/Pie/utils/geometry';
|
||||
|
||||
interface PieCenterLabelProps {
|
||||
/** Sum of the visible slice values, shown in the donut hole. */
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
|
||||
import PieCenterLabel from 'lib/visualization/charts/Pie/components/PieCenterLabel/PieCenterLabel';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getYAxisFormattedValue: jest.fn(),
|
||||
@@ -0,0 +1,39 @@
|
||||
// Mirrors the uPlot tooltip container (uPlotV2/components/Tooltip).
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
max-width: 80vw;
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8) var(--spacing-8) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activeItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
|
||||
import TooltipList from 'lib/uPlotV2/components/Tooltip/components/TooltipList/TooltipList';
|
||||
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
|
||||
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
|
||||
import PieTooltipFooter from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltipFooter';
|
||||
|
||||
import styles from 'lib/visualization/charts/Pie/components/PieTooltip/PieTooltip.module.scss';
|
||||
|
||||
interface PieTooltipContentProps {
|
||||
/** Widget id, for the list's analytics. */
|
||||
id: string;
|
||||
data: PieTooltipData;
|
||||
/** Pinned by a click on a grouped slice: its members become drill-down targets. */
|
||||
isPinned: boolean;
|
||||
canDrilldown: boolean;
|
||||
onMemberClick: (index: number, event: ReactMouseEvent) => void;
|
||||
onUnpin: () => void;
|
||||
}
|
||||
|
||||
/** Hovered slice's label and value, in the timeseries tooltip's look; a grouped slice also lists its members. */
|
||||
export default function PieTooltipContent({
|
||||
id,
|
||||
data,
|
||||
isPinned,
|
||||
canDrilldown,
|
||||
onMemberClick,
|
||||
onUnpin,
|
||||
}: PieTooltipContentProps): JSX.Element {
|
||||
const { slice, groupedMembers } = data;
|
||||
const canDrillMember = isPinned && canDrilldown;
|
||||
const showFooter = !!groupedMembers || canDrilldown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.body, {
|
||||
[styles.pinned]: isPinned,
|
||||
})}
|
||||
>
|
||||
{isPinned && (
|
||||
<div className={styles.headerRow}>
|
||||
<span>{groupedMembers?.length} series</span>
|
||||
<TooltipPinnedBadge />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.activeItem}>
|
||||
<TooltipItem item={slice} isItemActive />
|
||||
</div>
|
||||
{groupedMembers && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
<TooltipList
|
||||
id={id}
|
||||
content={groupedMembers}
|
||||
onItemClick={canDrillMember ? onMemberClick : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showFooter && (
|
||||
<PieTooltipFooter
|
||||
isGroup={!!groupedMembers}
|
||||
isPinned={isPinned}
|
||||
canDrilldown={canDrilldown}
|
||||
onUnpin={onUnpin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { MousePointerClick, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Kbd } from '@signozhq/ui/kbd';
|
||||
|
||||
import footerStyles from 'lib/visualization/panels/components/TooltipFooter.module.scss';
|
||||
|
||||
interface PieTooltipFooterProps {
|
||||
isGroup: boolean;
|
||||
isPinned: boolean;
|
||||
canDrilldown: boolean;
|
||||
onUnpin: () => void;
|
||||
}
|
||||
|
||||
/** The timeseries tooltip footer's look, with the pie's click-to-pin hints. */
|
||||
export default function PieTooltipFooter({
|
||||
isGroup,
|
||||
isPinned,
|
||||
canDrilldown,
|
||||
onUnpin,
|
||||
}: PieTooltipFooterProps): JSX.Element {
|
||||
let clickHint = canDrilldown ? 'Click to drilldown' : null;
|
||||
if (isGroup) {
|
||||
clickHint = canDrilldown
|
||||
? 'Click to pin, then pick a series'
|
||||
: 'Click to pin the tooltip';
|
||||
}
|
||||
if (isPinned) {
|
||||
clickHint = canDrilldown ? 'Click a series to drilldown' : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={footerStyles.footer} data-testid="pie-tooltip-footer">
|
||||
<div className={footerStyles.hintList}>
|
||||
{clickHint && (
|
||||
<div className={footerStyles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>{clickHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{isPinned && (
|
||||
<div className={footerStyles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>Esc</Kbd>
|
||||
<span>to unpin</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onUnpin}
|
||||
aria-label="Unpin tooltip"
|
||||
testId="pie-tooltip-unpin"
|
||||
prefix={<X size={10} />}
|
||||
>
|
||||
<span>Unpin</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
frontend/src/lib/visualization/charts/Pie/hooks/usePieTooltip.ts
Normal file
205
frontend/src/lib/visualization/charts/Pie/hooks/usePieTooltip.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { useTooltip } from '@visx/tooltip';
|
||||
|
||||
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
|
||||
import { PieTooltipData } from 'lib/visualization/charts/Pie/types';
|
||||
import { getSliceDisplayLabel } from 'lib/visualization/charts/Pie/utils/slices';
|
||||
|
||||
// Keeps the cursor off the tooltip so it never steals the hover.
|
||||
const TOOLTIP_CURSOR_OFFSET = 12;
|
||||
|
||||
interface UsePieTooltipArgs {
|
||||
/** The drawn slices, Other included. */
|
||||
slices: PieSlice[];
|
||||
/** The chart's root: a click on its own arcs resolves the pin, one anywhere else unpins. */
|
||||
chartRef: RefObject<HTMLElement>;
|
||||
formatValue: (value: number) => string;
|
||||
setFocusedKey: (key: string | null) => void;
|
||||
onSliceClick: PieChartProps['onSliceClick'];
|
||||
}
|
||||
|
||||
interface UsePieTooltipResult {
|
||||
tooltipOpen: boolean;
|
||||
tooltipTop?: number;
|
||||
tooltipLeft?: number;
|
||||
tooltipData?: PieTooltipData;
|
||||
isPinned: boolean;
|
||||
unpin: () => void;
|
||||
handleSliceHover: (
|
||||
slice: PieSlice,
|
||||
event: ReactMouseEvent<SVGGElement>,
|
||||
) => void;
|
||||
handleSliceLeave: () => void;
|
||||
handleSliceClick: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
handleMemberClick: (index: number, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Pie tooltip: follows the cursor over an arc, and pins on a grouped slice's click so its
|
||||
* members can be drilled into. Esc or a click outside unpins.
|
||||
*/
|
||||
export function usePieTooltip({
|
||||
slices,
|
||||
chartRef,
|
||||
formatValue,
|
||||
setFocusedKey,
|
||||
onSliceClick,
|
||||
}: UsePieTooltipArgs): UsePieTooltipResult {
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipLeft,
|
||||
tooltipTop,
|
||||
tooltipData,
|
||||
hideTooltip,
|
||||
showTooltip,
|
||||
} = useTooltip<PieTooltipData>();
|
||||
|
||||
// A grouped slice's click pins its tooltip so its members can be drilled into.
|
||||
const [pinnedSlice, setPinnedSlice] = useState<PieSlice | null>(null);
|
||||
|
||||
const unpin = useCallback((): void => {
|
||||
setPinnedSlice(null);
|
||||
hideTooltip();
|
||||
setFocusedKey(null);
|
||||
}, [hideTooltip, setFocusedKey]);
|
||||
|
||||
// New data replaces the pinned slice, whose members may no longer be drawn.
|
||||
useEffect(() => {
|
||||
if (pinnedSlice && !slices.includes(pinnedSlice)) {
|
||||
unpin();
|
||||
}
|
||||
}, [slices, pinnedSlice, unpin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pinnedSlice) {
|
||||
return undefined;
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const target = event.target as Element | null;
|
||||
// This chart's arc clicks resolve the pin themselves (see handleSliceClick).
|
||||
const isOwnArc =
|
||||
!!target?.closest('[data-pie-arc]') && !!chartRef.current?.contains(target);
|
||||
if (isOwnArc || target?.closest('[data-pie-tooltip]')) {
|
||||
return;
|
||||
}
|
||||
unpin();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
unpin();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return (): void => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [pinnedSlice, unpin, chartRef]);
|
||||
|
||||
// Built once per data change, not on every mouse move.
|
||||
const tooltipDataBySlice = useMemo(
|
||||
() =>
|
||||
new Map<PieSlice, PieTooltipData>(
|
||||
slices.map((slice) => [
|
||||
slice,
|
||||
{
|
||||
slice: {
|
||||
label: getSliceDisplayLabel(slice),
|
||||
value: slice.value,
|
||||
tooltipValue: formatValue(slice.value),
|
||||
color: slice.color,
|
||||
isActive: true,
|
||||
},
|
||||
groupedMembers: slice.groupedMembers?.map((member) => ({
|
||||
label: member.label,
|
||||
value: member.value,
|
||||
tooltipValue: formatValue(member.value),
|
||||
color: member.color,
|
||||
isActive: false,
|
||||
})),
|
||||
},
|
||||
]),
|
||||
),
|
||||
[slices, formatValue],
|
||||
);
|
||||
|
||||
const handleSliceHover = useCallback(
|
||||
(slice: PieSlice, event: ReactMouseEvent<SVGGElement>): void => {
|
||||
if (pinnedSlice) {
|
||||
return;
|
||||
}
|
||||
const svgRect = event.currentTarget.ownerSVGElement?.getBoundingClientRect();
|
||||
showTooltip({
|
||||
tooltipData: tooltipDataBySlice.get(slice),
|
||||
tooltipTop: event.clientY - (svgRect?.top ?? 0) + TOOLTIP_CURSOR_OFFSET,
|
||||
tooltipLeft: event.clientX - (svgRect?.left ?? 0) + TOOLTIP_CURSOR_OFFSET,
|
||||
});
|
||||
setFocusedKey(slice.label);
|
||||
},
|
||||
[pinnedSlice, showTooltip, setFocusedKey, tooltipDataBySlice],
|
||||
);
|
||||
|
||||
const handleSliceLeave = useCallback((): void => {
|
||||
if (pinnedSlice) {
|
||||
return;
|
||||
}
|
||||
hideTooltip();
|
||||
setFocusedKey(null);
|
||||
}, [pinnedSlice, hideTooltip, setFocusedKey]);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice, event: ReactMouseEvent): void => {
|
||||
if (slice.groupedMembers) {
|
||||
if (pinnedSlice === slice) {
|
||||
unpin();
|
||||
} else {
|
||||
setPinnedSlice(slice);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pinnedSlice) {
|
||||
unpin();
|
||||
}
|
||||
onSliceClick?.(slice, { x: event.clientX, y: event.clientY });
|
||||
},
|
||||
[pinnedSlice, unpin, onSliceClick],
|
||||
);
|
||||
|
||||
const handleMemberClick = useCallback(
|
||||
(index: number, event: ReactMouseEvent): void => {
|
||||
const member = pinnedSlice?.groupedMembers?.[index];
|
||||
const tooltipRect = (event.currentTarget as Element)
|
||||
.closest('[data-pie-tooltip]')
|
||||
?.getBoundingClientRect();
|
||||
unpin();
|
||||
if (member) {
|
||||
onSliceClick?.(member, {
|
||||
x: tooltipRect?.left ?? event.clientX,
|
||||
y: tooltipRect?.top ?? event.clientY,
|
||||
});
|
||||
}
|
||||
},
|
||||
[pinnedSlice, unpin, onSliceClick],
|
||||
);
|
||||
|
||||
return {
|
||||
tooltipOpen,
|
||||
tooltipTop,
|
||||
tooltipLeft,
|
||||
tooltipData,
|
||||
isPinned: !!pinnedSlice,
|
||||
unpin,
|
||||
handleSliceHover,
|
||||
handleSliceLeave,
|
||||
handleSliceClick,
|
||||
handleMemberClick,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
* (PieSlice, PieChartProps) live in the parent charts/types.ts.
|
||||
*/
|
||||
|
||||
import type { TooltipContentItem } from 'lib/uPlotV2/components/types';
|
||||
|
||||
export interface ScaledFontSizeArgs {
|
||||
text: string;
|
||||
baseSize: number;
|
||||
@@ -39,7 +41,7 @@ export interface ParsedRgb {
|
||||
|
||||
/** Resolved tooltip payload shown when a slice is hovered. */
|
||||
export interface PieTooltipData {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
slice: TooltipContentItem;
|
||||
/** Grouped slice only: every member, largest first. */
|
||||
groupedMembers?: TooltipContentItem[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
getFillColor,
|
||||
lightenColor,
|
||||
} from 'lib/visualization/charts/Pie/utils/colors';
|
||||
|
||||
describe('Pie colors', () => {
|
||||
describe('lightenColor', () => {
|
||||
it('converts a #rrggbb hex to rgba at the given opacity', () => {
|
||||
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('accepts hex without a leading #', () => {
|
||||
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('returns the original colour when it is not parseable hex', () => {
|
||||
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
|
||||
expect(lightenColor('red', 0.4)).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFillColor', () => {
|
||||
it('returns the colour unchanged when nothing is active', () => {
|
||||
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('returns the colour unchanged for the active slice', () => {
|
||||
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('dims non-active slices to 40% opacity', () => {
|
||||
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import {
|
||||
getArcGeometry,
|
||||
getDonutGeometry,
|
||||
getFillColor,
|
||||
getScaledFontSize,
|
||||
lightenColor,
|
||||
} from 'lib/visualization/charts/Pie/utils';
|
||||
} from 'lib/visualization/charts/Pie/utils/geometry';
|
||||
|
||||
describe('Pie utils', () => {
|
||||
describe('Pie geometry', () => {
|
||||
describe('getDonutGeometry', () => {
|
||||
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
|
||||
const { radius } = getDonutGeometry(400, 300);
|
||||
@@ -98,33 +96,4 @@ describe('Pie utils', () => {
|
||||
expect(g.textAnchor).toBe('end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lightenColor', () => {
|
||||
it('converts a #rrggbb hex to rgba at the given opacity', () => {
|
||||
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('accepts hex without a leading #', () => {
|
||||
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('returns the original colour when it is not parseable hex', () => {
|
||||
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
|
||||
expect(lightenColor('red', 0.4)).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFillColor', () => {
|
||||
it('returns the colour unchanged when nothing is active', () => {
|
||||
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('returns the colour unchanged for the active slice', () => {
|
||||
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('dims non-active slices to 40% opacity', () => {
|
||||
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { PieSlice } from 'lib/visualization/charts/types';
|
||||
|
||||
import {
|
||||
groupPieSlices,
|
||||
OTHER_SLICE_LABEL,
|
||||
} from 'lib/visualization/charts/Pie/utils/groupPieSlices';
|
||||
|
||||
const slice = (label: string, value: number): PieSlice => ({
|
||||
label,
|
||||
value,
|
||||
color: '#000000',
|
||||
queryName: 'A',
|
||||
});
|
||||
|
||||
const group = (slices: PieSlice[]): PieSlice[] => groupPieSlices(slices, true);
|
||||
|
||||
describe('groupPieSlices', () => {
|
||||
it('folds a long tail into a trailing Other, keeping kept-slice order', () => {
|
||||
const slices = [
|
||||
slice('b', 400),
|
||||
slice('tail1', 2),
|
||||
slice('a', 500),
|
||||
slice('tail2', 4),
|
||||
slice('tail3', 1),
|
||||
];
|
||||
|
||||
const result = group(slices);
|
||||
|
||||
expect(result.map((s) => [s.label, s.value])).toStrictEqual([
|
||||
['b', 400],
|
||||
['a', 500],
|
||||
[OTHER_SLICE_LABEL, 7],
|
||||
]);
|
||||
expect(result[2].groupedMembers?.map((s) => s.label)).toStrictEqual([
|
||||
'tail2',
|
||||
'tail1',
|
||||
'tail3',
|
||||
]);
|
||||
expect(result[2].queryName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('caps a flat pie at 14 slices + Other', () => {
|
||||
const slices = Array.from({ length: 30 }, (_, i) => slice(`s${i}`, 100 - i));
|
||||
|
||||
const result = group(slices);
|
||||
|
||||
expect(result).toHaveLength(15);
|
||||
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
|
||||
slices.slice(0, 14).map((s) => s.label),
|
||||
);
|
||||
expect(result[14].label).toBe(OTHER_SLICE_LABEL);
|
||||
expect(result[14].groupedMembers).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('keeps exactly 15 large slices ungrouped', () => {
|
||||
const slices = Array.from({ length: 15 }, (_, i) => slice(`s${i}`, 10));
|
||||
|
||||
expect(group(slices)).toBe(slices);
|
||||
});
|
||||
|
||||
it('leaves a single small slice under its own name', () => {
|
||||
const slices = [slice('a', 500), slice('b', 400), slice('tiny', 1)];
|
||||
|
||||
expect(group(slices)).toBe(slices);
|
||||
});
|
||||
|
||||
it('keeps a dominant Other at its true size and conserves the total', () => {
|
||||
const slices = [
|
||||
slice('a', 300),
|
||||
...Array.from({ length: 400 }, (_, i) => slice(`t${i}`, 1)),
|
||||
];
|
||||
|
||||
const result = group(slices);
|
||||
|
||||
expect(result.map((s) => s.label)).toStrictEqual(['a', OTHER_SLICE_LABEL]);
|
||||
expect(result[1].value).toBe(400);
|
||||
expect(result[1].groupedMembers).toHaveLength(400);
|
||||
});
|
||||
|
||||
it('keeps tiny slices grouped past 15 series', () => {
|
||||
const slices = [
|
||||
slice('a', 3000),
|
||||
slice('b', 2000),
|
||||
slice('c', 1000),
|
||||
...Array.from({ length: 13 }, (_, i) => slice(`t${i}`, 1)),
|
||||
];
|
||||
|
||||
const result = group(slices);
|
||||
|
||||
expect(result.map((s) => s.label)).toStrictEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
OTHER_SLICE_LABEL,
|
||||
]);
|
||||
expect(result[3].groupedMembers).toHaveLength(13);
|
||||
});
|
||||
|
||||
it('keeps 14 slices when every slice is under the share threshold', () => {
|
||||
// 200 × 0.5%: grouping by share alone would leave one 100% Other.
|
||||
const slices = Array.from({ length: 200 }, (_, i) => slice(`s${i}`, 5));
|
||||
|
||||
const result = group(slices);
|
||||
|
||||
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
|
||||
slices.slice(0, 14).map((s) => s.label),
|
||||
);
|
||||
expect(result[14].label).toBe(OTHER_SLICE_LABEL);
|
||||
expect(result[14].groupedMembers).toHaveLength(186);
|
||||
expect(result[14].value).toBe(930);
|
||||
});
|
||||
|
||||
it('breaks ties by input order', () => {
|
||||
const slices = Array.from({ length: 20 }, (_, i) => slice(`s${i}`, 5));
|
||||
|
||||
const result = group(slices);
|
||||
|
||||
expect(result.slice(0, 14).map((s) => s.label)).toStrictEqual(
|
||||
slices.slice(0, 14).map((s) => s.label),
|
||||
);
|
||||
expect(result[14].groupedMembers?.map((s) => s.label)).toStrictEqual(
|
||||
slices.slice(14).map((s) => s.label),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty pie unchanged', () => {
|
||||
expect(group([])).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PieSlice } from 'lib/visualization/charts/types';
|
||||
import { compareSlices } from 'lib/visualization/charts/Pie/utils/slices';
|
||||
|
||||
describe('Pie slices', () => {
|
||||
describe('compareSlices', () => {
|
||||
it('orders largest first with the grouped slice last', () => {
|
||||
const slice = (label: string, value: number, grouped = false): PieSlice => ({
|
||||
label,
|
||||
value,
|
||||
color: '#000000',
|
||||
...(grouped ? { groupedMembers: [] } : {}),
|
||||
});
|
||||
const slices = [
|
||||
slice('small', 1),
|
||||
slice('Other', 500, true),
|
||||
slice('big', 100),
|
||||
];
|
||||
|
||||
expect([...slices].sort(compareSlices).map((s) => s.label)).toStrictEqual([
|
||||
'big',
|
||||
'small',
|
||||
'Other',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/src/lib/visualization/charts/Pie/utils/colors.ts
Normal file
41
frontend/src/lib/visualization/charts/Pie/utils/colors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ParsedRgb } from 'lib/visualization/charts/Pie/types';
|
||||
|
||||
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
|
||||
// already-rgba string), letting callers fall back to the original colour.
|
||||
function hexToRgb(color: string): ParsedRgb | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an rgba() string for `color` at the given opacity. Used to dim the
|
||||
* non-hovered slices. Falls back to the original colour if it can't be parsed.
|
||||
*/
|
||||
export function lightenColor(color: string, opacity: number): string {
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
return color;
|
||||
}
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fill for a slice given the currently-hovered slice colour:
|
||||
* everything but the active slice dims to 40% opacity. With nothing hovered
|
||||
* (`activeColor === null`) every slice keeps its full colour.
|
||||
*/
|
||||
export function getFillColor(
|
||||
color: string,
|
||||
activeColor: string | null,
|
||||
): string {
|
||||
if (activeColor === null) {
|
||||
return color;
|
||||
}
|
||||
return activeColor === color ? color : lightenColor(color, 0.4);
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
/**
|
||||
* Pure presentation helpers for the Pie chart. Kept out of the component file
|
||||
* so the renderer stays declarative (per the one-component-per-file rule).
|
||||
*/
|
||||
/** Donut, label and font sizing for the Pie chart. */
|
||||
|
||||
import {
|
||||
ArcGeometry,
|
||||
DonutGeometry,
|
||||
ParsedRgb,
|
||||
ScaledFontSizeArgs,
|
||||
} from 'lib/visualization/charts/Pie/types';
|
||||
|
||||
@@ -77,43 +73,3 @@ export function getArcGeometry(
|
||||
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
|
||||
};
|
||||
}
|
||||
|
||||
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
|
||||
// already-rgba string), letting callers fall back to the original colour.
|
||||
function hexToRgb(color: string): ParsedRgb | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an rgba() string for `color` at the given opacity. Used to dim the
|
||||
* non-hovered slices. Falls back to the original colour if it can't be parsed.
|
||||
*/
|
||||
export function lightenColor(color: string, opacity: number): string {
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
return color;
|
||||
}
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fill for a slice given the currently-hovered slice colour:
|
||||
* everything but the active slice dims to 40% opacity. With nothing hovered
|
||||
* (`activeColor === null`) every slice keeps its full colour.
|
||||
*/
|
||||
export function getFillColor(
|
||||
color: string,
|
||||
activeColor: string | null,
|
||||
): string {
|
||||
if (activeColor === null) {
|
||||
return color;
|
||||
}
|
||||
return activeColor === color ? color : lightenColor(color, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { PieSlice } from 'lib/visualization/charts/types';
|
||||
|
||||
export const OTHER_SLICE_LABEL = 'Other';
|
||||
|
||||
const MIN_SHARE = 0.01;
|
||||
const MAX_SLICES = 15;
|
||||
const MIN_OTHER_MEMBERS = 2;
|
||||
|
||||
/**
|
||||
* Folds slices at or under `MIN_SHARE`, plus any past `MAX_SLICES` (Other included), into one
|
||||
* trailing "Other" slice. When no slice clears `MIN_SHARE`, the largest `MAX_SLICES - 1` stay so
|
||||
* the pie never collapses into a single Other. Kept slices keep their order. No-op when fewer
|
||||
* than two would fold.
|
||||
*/
|
||||
export function groupPieSlices(
|
||||
slices: PieSlice[],
|
||||
isDarkMode: boolean,
|
||||
): PieSlice[] {
|
||||
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
||||
const sorted = [...slices].sort((a, b) => b.value - a.value);
|
||||
const aboveShare = sorted.filter(
|
||||
(slice) => slice.value / total > MIN_SHARE,
|
||||
).length;
|
||||
const keptCount = Math.min(aboveShare || MAX_SLICES - 1, MAX_SLICES - 1);
|
||||
|
||||
const groupedMembers = sorted.slice(keptCount);
|
||||
if (groupedMembers.length < MIN_OTHER_MEMBERS) {
|
||||
return slices;
|
||||
}
|
||||
|
||||
const kept = new Set(sorted.slice(0, keptCount));
|
||||
const other: PieSlice = {
|
||||
label: OTHER_SLICE_LABEL,
|
||||
value: groupedMembers.reduce((sum, slice) => sum + slice.value, 0),
|
||||
color: isDarkMode ? Color.BG_SLATE_50 : Color.BG_VANILLA_400,
|
||||
groupedMembers,
|
||||
};
|
||||
return [...slices.filter((slice) => kept.has(slice)), other];
|
||||
}
|
||||
15
frontend/src/lib/visualization/charts/Pie/utils/slices.ts
Normal file
15
frontend/src/lib/visualization/charts/Pie/utils/slices.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { PieSlice } from 'lib/visualization/charts/types';
|
||||
|
||||
/** Grouped slices read "Other (n)"; `label` stays the key for colours and visibility. */
|
||||
export function getSliceDisplayLabel(slice: PieSlice): string {
|
||||
return slice.groupedMembers
|
||||
? `${slice.label} (${slice.groupedMembers.length})`
|
||||
: slice.label;
|
||||
}
|
||||
|
||||
/** Arc order: largest first, with a grouped slice always last so it closes the circle. */
|
||||
export function compareSlices(a: PieSlice, b: PieSlice): number {
|
||||
return (
|
||||
Number(!!a.groupedMembers) - Number(!!b.groupedMembers) || b.value - a.value
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import uPlot from 'uplot';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -180,6 +179,8 @@ export interface PieSlice {
|
||||
queryName?: string;
|
||||
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
|
||||
labels?: Record<string, string>;
|
||||
/** Slices folded into this one, largest first. Present only on the "Other" slice. */
|
||||
groupedMembers?: PieSlice[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,8 +201,11 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
/**
|
||||
* Fired when a slice is clicked, with the viewport point to anchor the drill-down popover:
|
||||
* the cursor for an arc, the pinned tooltip's corner for a grouped slice's member.
|
||||
*/
|
||||
onSliceClick?: (slice: PieSlice, anchor: { x: number; y: number }) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,13 +107,9 @@ function JsonEditorDrawer({
|
||||
} else if (!readOnly && !validity.valid) {
|
||||
applyDisabledReason = 'Fix the JSON errors first';
|
||||
}
|
||||
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 validationText = validity.valid
|
||||
? `Valid JSON · ${validity.lineCount} lines`
|
||||
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
|
||||
const plural = (n: number): string => (n === 1 ? '' : 's');
|
||||
const danglingWarning =
|
||||
danglingPanelIds.length > 0
|
||||
@@ -139,17 +135,15 @@ function JsonEditorDrawer({
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerStatus}>
|
||||
<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>
|
||||
<Typography.Text
|
||||
className={cx(styles.validation, {
|
||||
[styles.validationValid]: validity.valid,
|
||||
[styles.validationInvalid]: !validity.valid,
|
||||
})}
|
||||
data-testid="json-editor-validation"
|
||||
>
|
||||
{validationText}
|
||||
</Typography.Text>
|
||||
{danglingWarning && (
|
||||
<Tooltip title={danglingPanelIds.join(', ')}>
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer';
|
||||
@@ -170,36 +170,6 @@ 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(
|
||||
|
||||
@@ -95,54 +95,6 @@ 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() }),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -12,10 +12,8 @@ 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 {
|
||||
@@ -111,7 +109,7 @@ export function useJsonEditor({
|
||||
|
||||
const validity = useMemo<JsonValidity>(() => {
|
||||
const lineCount = draft.split('\n').length;
|
||||
let parsed: { image?: unknown; spec?: { duration?: unknown } };
|
||||
let parsed: { image?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(draft);
|
||||
} catch (error) {
|
||||
@@ -133,21 +131,10 @@ 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]);
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
@@ -1,131 +0,0 @@
|
||||
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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -32,7 +31,6 @@ 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],
|
||||
@@ -43,8 +41,6 @@ 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);
|
||||
@@ -57,7 +53,6 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDefaultTimeRange(defaultTimeRange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
@@ -107,9 +102,6 @@ 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,
|
||||
@@ -121,8 +113,6 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
dashboard.image,
|
||||
updatedTags,
|
||||
tagsAsStrings,
|
||||
updatedDefaultTimeRange,
|
||||
defaultTimeRange,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(async (): Promise<void> => {
|
||||
@@ -145,19 +135,12 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
const initialValues = [
|
||||
title,
|
||||
description,
|
||||
tagsAsStrings,
|
||||
image,
|
||||
defaultTimeRange,
|
||||
];
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
updatedDefaultTimeRange,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
@@ -170,12 +153,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
defaultTimeRange,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
updatedDefaultTimeRange,
|
||||
]);
|
||||
|
||||
const discardHandler = useCallback((): void => {
|
||||
@@ -183,9 +164,8 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedDefaultTimeRange(defaultTimeRange);
|
||||
void logEvent(DashboardDetailEvents.OverviewDiscarded, { dashboardId: id });
|
||||
}, [title, image, tagsAsStrings, description, defaultTimeRange, id]);
|
||||
}, [title, image, tagsAsStrings, description, id]);
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
@@ -199,10 +179,6 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
onImageChange={setUpdatedImage}
|
||||
onTagsChange={setUpdatedTags}
|
||||
/>
|
||||
<DefaultTimeRange
|
||||
value={updatedDefaultTimeRange}
|
||||
onChange={setUpdatedDefaultTimeRange}
|
||||
/>
|
||||
<CrossPanelSync dashboardId={id} />
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<UnsavedChangesFooter
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -25,41 +22,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Pie from 'lib/visualization/charts/Pie/Pie';
|
||||
import type { PieSlice } from 'lib/visualization/charts/types';
|
||||
@@ -75,14 +71,14 @@ function PiePanelRenderer({
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice, event: ReactMouseEvent): void => {
|
||||
(slice: PieSlice, coordinates: { x: number; y: number }): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichPieClick({
|
||||
slice,
|
||||
builderQueries,
|
||||
coordinates: { x: event.clientX, y: event.clientY },
|
||||
coordinates,
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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 {
|
||||
@@ -34,9 +33,8 @@ function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
canEditDashboardOverride,
|
||||
}: DashboardContainerProps): JSX.Element | null {
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
const spec = dashboard.spec;
|
||||
const isTimeInitialized = useDashboardDefaultTime(dashboard.id, spec.duration);
|
||||
const image = resolveDashboardImage(dashboard.image);
|
||||
const name = spec.display.name;
|
||||
|
||||
@@ -70,10 +68,6 @@ 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 (
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -547,18 +547,6 @@ 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);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
PANEL_KINDS,
|
||||
PANEL_RESULTS,
|
||||
PANELS_DASHBOARD_ID,
|
||||
PIE_SLICES,
|
||||
SPAN_GAPS,
|
||||
STACK_MODES,
|
||||
TEXT_ALIGNS,
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
PanelKind,
|
||||
PanelResult,
|
||||
PanelUnit,
|
||||
PieSlices,
|
||||
SpanGaps,
|
||||
TextSample,
|
||||
YScale,
|
||||
@@ -199,6 +201,13 @@ export const panelsMocks = defineStoryMocks({
|
||||
value: 4,
|
||||
max: MAX_SERIES,
|
||||
}),
|
||||
pieSlices: choiceControl<PieSlices>('Pie slices', {
|
||||
group: DATA,
|
||||
description:
|
||||
'`even` steps down per service (Series sets how many); `long-tail` answers 24 services, the smallest 10 folding into Other.',
|
||||
options: PIE_SLICES,
|
||||
value: 'even',
|
||||
}),
|
||||
gaps: choiceControl<Gaps>('Gaps', {
|
||||
group: DATA,
|
||||
description:
|
||||
@@ -267,6 +276,7 @@ export const panelsMocks = defineStoryMocks({
|
||||
listRows: values.listRows,
|
||||
listJsonBody: values.listJsonBody,
|
||||
gaps: values.gaps,
|
||||
pieSlices: values.pieSlices,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import {
|
||||
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
|
||||
DashboardtypesLegendPositionDTO as LegendPosition,
|
||||
@@ -16,9 +15,13 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
import { panelsMocks } from './Panels.stories.mocks';
|
||||
import { expectPanel } from './panelPlay/expectPanel';
|
||||
import { filterLegend, searchTable } from './panelPlay/interactions';
|
||||
import { walkListPages } from './panelPlay/list';
|
||||
import {
|
||||
playLegendRight,
|
||||
playListJsonBody,
|
||||
playListPaginated,
|
||||
playPanel,
|
||||
playTableSearch,
|
||||
} from './panelPlay/plays';
|
||||
|
||||
type PanelsArgs = PageStoryArgs<typeof panelsMocks>;
|
||||
|
||||
@@ -49,53 +52,49 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<PanelsArgs>;
|
||||
|
||||
const play: Story['play'] = async ({ canvasElement, args }) => {
|
||||
await expectPanel(canvasElement, args);
|
||||
};
|
||||
|
||||
/** A time series panel: p99 latency, one line per service, with a bottom legend. */
|
||||
export const Default: Story = { play };
|
||||
export const Default: Story = { play: playPanel };
|
||||
|
||||
/** One bar series per service over time, side by side. */
|
||||
export const Bar: Story = {
|
||||
args: { kind: 'Bar' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The services' bars stacked on one another at every moment. */
|
||||
export const BarStacked: Story = {
|
||||
args: { kind: 'Bar', stack: StackMode.normal },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The services as filled areas, drawn independently. */
|
||||
export const Area: Story = {
|
||||
args: { kind: 'Area' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The services' areas stacked on one another, each band filling to the one below. */
|
||||
export const AreaStacked: Story = {
|
||||
args: { kind: 'Area', stack: StackMode.normal },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The stacked areas rescaled so every moment sums to 100%, each band its service's share. */
|
||||
export const AreaPercent: Story = {
|
||||
args: { kind: 'Area', stack: StackMode.percent },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** How the latency values distribute across buckets, one bar set per service. */
|
||||
export const Histogram: Story = {
|
||||
args: { kind: 'Histogram' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The histogram binned 50 apart, every bin starting on a multiple of 50. */
|
||||
export const HistogramBucketWidth: Story = {
|
||||
args: { kind: 'Histogram', bucketWidth: '50' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -104,25 +103,34 @@ export const HistogramBucketWidth: Story = {
|
||||
*/
|
||||
export const Heatmap: Story = {
|
||||
args: { kind: 'Heatmap' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The heatmap's bucket rows laid out on a log scale. */
|
||||
export const HeatmapLog: Story = {
|
||||
args: { kind: 'Heatmap', heatmapScale: HeatmapYScale.log },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The heatmap's colour bar pinned to a fixed 5–250 count range. */
|
||||
export const HeatmapCountRange: Story = {
|
||||
args: { kind: 'Heatmap', heatmapCounts: 'fixed' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** Each service's share of traffic as a donut, with the total in the middle. */
|
||||
export const Pie: Story = {
|
||||
args: { kind: 'Pie' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
* 24 services of falling traffic: the smallest 10 fold into one grey "Other (10)" slice,
|
||||
* which lists them on hover; click pins the list to drill into any of them.
|
||||
*/
|
||||
export const PieOther: Story = {
|
||||
args: { kind: 'Pie', pieSlices: 'long-tail' },
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,13 +139,13 @@ export const Pie: Story = {
|
||||
*/
|
||||
export const Number: Story = {
|
||||
args: { kind: 'Number', thresholds: true },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The value's query matched nothing, which the Number panel draws as its own no-data state. */
|
||||
export const NumberNoData: Story = {
|
||||
args: { kind: 'Number', result: 'empty' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -146,7 +154,7 @@ export const NumberNoData: Story = {
|
||||
*/
|
||||
export const Table: Story = {
|
||||
args: { kind: 'Table', thresholds: true, columnUnits: true },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -155,16 +163,13 @@ export const Table: Story = {
|
||||
*/
|
||||
export const TableSearch: Story = {
|
||||
args: { kind: 'Table' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectPanel(canvasElement, args);
|
||||
await searchTable(canvasElement, args);
|
||||
},
|
||||
play: playTableSearch,
|
||||
};
|
||||
|
||||
/** The latest log lines, newest first, on a single page. */
|
||||
export const List: Story = {
|
||||
args: { kind: 'List' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -173,16 +178,7 @@ export const List: Story = {
|
||||
*/
|
||||
export const ListJsonBody: Story = {
|
||||
args: { kind: 'List', listJsonBody: true },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectPanel(canvasElement, args);
|
||||
const [first, , third] = within(canvasElement)
|
||||
.getByTestId('list-panel-renderer')
|
||||
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
|
||||
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
|
||||
await expect(
|
||||
within(third).getByText(/^\{"message":"log line 3:/),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
play: playListJsonBody,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -191,28 +187,25 @@ export const ListJsonBody: Story = {
|
||||
*/
|
||||
export const ListPaginated: Story = {
|
||||
args: { kind: 'List', listRows: 60 },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectPanel(canvasElement, args);
|
||||
await walkListPages(canvasElement, args);
|
||||
},
|
||||
play: playListPaginated,
|
||||
};
|
||||
|
||||
/** A Markdown runbook: heading, list, link and code block. */
|
||||
export const Text: Story = {
|
||||
args: { kind: 'Text' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The time series on a logarithmic y axis. */
|
||||
export const LogScale: Story = {
|
||||
args: { yScale: 'log' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** Dashed lines with a marker on every sample. */
|
||||
export const LineStyle: Story = {
|
||||
args: { lineStyle: LineStyleDTO.dashed, showPoints: true },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -221,7 +214,7 @@ export const LineStyle: Story = {
|
||||
*/
|
||||
export const SpanGaps: Story = {
|
||||
args: { gaps: 'hole', spanGaps: 'break-10m' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -230,26 +223,23 @@ export const SpanGaps: Story = {
|
||||
*/
|
||||
export const LegendRight: Story = {
|
||||
args: { legend: LegendPosition.right },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await expectPanel(canvasElement, args);
|
||||
await filterLegend(canvasElement, args);
|
||||
},
|
||||
play: playLegendRight,
|
||||
};
|
||||
|
||||
/** The dashed SLO line across the time series, with its label. */
|
||||
export const Thresholds: Story = {
|
||||
args: { thresholds: true },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** The query ran and matched nothing, which the panel draws as its no-data state. */
|
||||
export const NoData: Story = {
|
||||
args: { result: 'empty' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
/** Data that came back with a warning, flagged in the panel header. */
|
||||
export const Warning: Story = {
|
||||
args: { result: 'warning' },
|
||||
play,
|
||||
play: playPanel,
|
||||
};
|
||||
|
||||
@@ -67,6 +67,11 @@ export const LINE_STYLES = Object.values(LineStyle);
|
||||
|
||||
export const GAPS = ['none', 'hole'] as const;
|
||||
|
||||
export const PIE_SLICES = ['even', 'long-tail'] as const;
|
||||
|
||||
/** Slices `long-tail` answers with: 14 drawn, the other 10 grouped (the pie draws at most 15). */
|
||||
export const LONG_TAIL_SLICES = 24;
|
||||
|
||||
export const SPAN_GAPS = ['bridge', 'break-10m'] as const;
|
||||
|
||||
/** Points cut out of every series by `hole`: 12 of the 30, a gap past the 10m break. */
|
||||
|
||||
@@ -26,13 +26,14 @@ import {
|
||||
HEATMAP_BUCKETS,
|
||||
HEATMAP_COLUMNS,
|
||||
HOLE,
|
||||
LONG_TAIL_SLICES,
|
||||
MAX_SERIES,
|
||||
NUMBER_VALUE,
|
||||
QUERY_NAME,
|
||||
SERVICES,
|
||||
TABLE_SECOND_COLUMN,
|
||||
} from './constants';
|
||||
import type { Gaps, PanelRequest, PanelResponseArgs } from './types';
|
||||
import type { Gaps, PanelRequest, PanelResponseArgs, PieSlices } from './types';
|
||||
|
||||
export const serviceAt = (index: number): string =>
|
||||
index < SERVICES.length
|
||||
@@ -42,6 +43,14 @@ export const serviceAt = (index: number): string =>
|
||||
export const TABLE_VALUES = (count: number): number[] =>
|
||||
Array.from({ length: count }, (_unused, index) => 3800 - index * 480);
|
||||
|
||||
/** The pie's slice values: `even` steps down per series, `long-tail` falls off like a power law. */
|
||||
export const PIE_VALUES = (slices: PieSlices, count: number): number[] =>
|
||||
slices === 'even'
|
||||
? TABLE_VALUES(count)
|
||||
: Array.from({ length: LONG_TAIL_SLICES }, (_unused, index) =>
|
||||
Math.round(42000 / (index + 1) ** 1.35),
|
||||
);
|
||||
|
||||
const TABLE_SECOND_VALUES = (count: number): number[] =>
|
||||
Array.from({ length: count }, (_unused, index) => 2000 + index * 600);
|
||||
|
||||
@@ -192,6 +201,7 @@ export const panelQueryResponse = ({
|
||||
listRows,
|
||||
listJsonBody,
|
||||
gaps,
|
||||
pieSlices,
|
||||
window,
|
||||
offset,
|
||||
limit,
|
||||
@@ -209,7 +219,10 @@ export const panelQueryResponse = ({
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
groupBy: ['service.name'],
|
||||
aggregations: [QUERY_NAME],
|
||||
rows: TABLE_VALUES(series).map((value, index) => [serviceAt(index), value]),
|
||||
rows: PIE_VALUES(pieSlices, series).map((value, index) => [
|
||||
serviceAt(index),
|
||||
value,
|
||||
]),
|
||||
warning,
|
||||
});
|
||||
case 'Table': {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
HEATMAP_COUNT_RANGES,
|
||||
PANEL_KINDS,
|
||||
PANEL_RESULTS,
|
||||
PIE_SLICES,
|
||||
SPAN_GAPS,
|
||||
TEXT_SAMPLES,
|
||||
UNITS,
|
||||
@@ -44,6 +45,8 @@ export type Gaps = (typeof GAPS)[number];
|
||||
|
||||
export type SpanGaps = (typeof SPAN_GAPS)[number];
|
||||
|
||||
export type PieSlices = (typeof PIE_SLICES)[number];
|
||||
|
||||
/** The controls that shape the dashboard document. */
|
||||
export interface PanelArgs {
|
||||
kind: PanelKind;
|
||||
@@ -79,4 +82,5 @@ export interface PanelResponseArgs extends PanelRequest {
|
||||
listRows: number;
|
||||
listJsonBody: boolean;
|
||||
gaps: Gaps;
|
||||
pieSlices: PieSlices;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
import type { PanelPlayArgs } from './args';
|
||||
import { expectPanel } from './expectPanel';
|
||||
import { filterLegend, searchTable } from './interactions';
|
||||
import { walkListPages } from './list';
|
||||
|
||||
type PanelPlay = (context: {
|
||||
canvasElement: HTMLElement;
|
||||
args: PanelPlayArgs;
|
||||
}) => Promise<void>;
|
||||
|
||||
/** Asserts what the panel kind draws. */
|
||||
export const playPanel: PanelPlay = async ({ canvasElement, args }) => {
|
||||
await expectPanel(canvasElement, args);
|
||||
};
|
||||
|
||||
export const playTableSearch: PanelPlay = async ({ canvasElement, args }) => {
|
||||
await expectPanel(canvasElement, args);
|
||||
await searchTable(canvasElement, args);
|
||||
};
|
||||
|
||||
export const playListJsonBody: PanelPlay = async ({ canvasElement, args }) => {
|
||||
await expectPanel(canvasElement, args);
|
||||
const [first, , third] = within(canvasElement)
|
||||
.getByTestId('list-panel-renderer')
|
||||
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
|
||||
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
|
||||
await expect(
|
||||
within(third).getByText(/^\{"message":"log line 3:/),
|
||||
).toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const playListPaginated: PanelPlay = async ({ canvasElement, args }) => {
|
||||
await expectPanel(canvasElement, args);
|
||||
await walkListPages(canvasElement, args);
|
||||
};
|
||||
|
||||
export const playLegendRight: PanelPlay = async ({ canvasElement, args }) => {
|
||||
await expectPanel(canvasElement, args);
|
||||
await filterLegend(canvasElement, args);
|
||||
};
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
TABLE_THRESHOLD_VALUE,
|
||||
} from '../__story_mockdata__/constants';
|
||||
import {
|
||||
PIE_VALUES,
|
||||
serviceIndex,
|
||||
TABLE_VALUES,
|
||||
} from '../__story_mockdata__/panelResponses';
|
||||
|
||||
import { PANEL_LOAD, type PanelPlayArgs } from './args';
|
||||
|
||||
// `long-tail` draws 14 slices and folds the other 10 into Other.
|
||||
const LONG_TAIL_KEPT = 14;
|
||||
|
||||
export const expectPie = async (
|
||||
root: HTMLElement,
|
||||
args: PanelPlayArgs,
|
||||
@@ -19,9 +23,18 @@ export const expectPie = async (
|
||||
const chart = await within(root).findByTestId('pie-chart', {}, PANEL_LOAD);
|
||||
// The legend's icons are SVGs too; the donut is the first one.
|
||||
const donut = chart.querySelector('svg');
|
||||
await expect(donut?.querySelectorAll('g > path')).toHaveLength(args.series);
|
||||
const values = PIE_VALUES(args.pieSlices, args.series);
|
||||
const isGrouped = args.pieSlices === 'long-tail';
|
||||
await expect(donut?.querySelectorAll('g > path')).toHaveLength(
|
||||
isGrouped ? LONG_TAIL_KEPT + 1 : values.length,
|
||||
);
|
||||
if (isGrouped) {
|
||||
await expect(
|
||||
within(chart).getByText(`Other (${values.length - LONG_TAIL_KEPT})`),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
// The centre shows the slices' sum, formatted as the panel formats values.
|
||||
const total = TABLE_VALUES(args.series).reduce((sum, value) => sum + value, 0);
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
await expect(
|
||||
donut?.querySelector('text[text-anchor="middle"]')?.textContent,
|
||||
).toBe(getYAxisFormattedValue(String(total), args.unit).replace(/\s+/g, ''));
|
||||
|
||||
@@ -7,11 +7,7 @@ import { screen, userEvent, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
clickWhenEnabled,
|
||||
dashboardMocks,
|
||||
openSettings,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
import { dashboardMocks, openSettings } from '../DashboardPage.stories.mocks';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
@@ -140,7 +136,7 @@ export const PublishedToast: Story = {
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await clickWhenEnabled('public-dashboard-publish');
|
||||
await userEvent.click(await screen.findByTestId('public-dashboard-publish'));
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Dashboard published successfully');
|
||||
}
|
||||
@@ -151,7 +147,7 @@ export const PublishedToast: Story = {
|
||||
export const PublicUpdatedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await clickWhenEnabled('public-dashboard-update');
|
||||
await userEvent.click(await screen.findByTestId('public-dashboard-update'));
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Public dashboard updated successfully');
|
||||
}
|
||||
@@ -162,7 +158,9 @@ export const PublicUpdatedToast: Story = {
|
||||
export const UnpublishedToast: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await clickWhenEnabled('public-dashboard-unpublish');
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('public-dashboard-unpublish'),
|
||||
);
|
||||
if (args.publicWrite === 'success') {
|
||||
await screen.findByText('Dashboard unpublished successfully');
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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';
|
||||
@@ -47,10 +46,10 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
reader.onload = (event): void => {
|
||||
try {
|
||||
const target = event.target?.result;
|
||||
if (typeof target !== 'string') {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(target);
|
||||
const parsed = JSON.parse(target.toString());
|
||||
setEditorValue(JSON.stringify(parsed, null, 2));
|
||||
setIsUploadError(false);
|
||||
} catch {
|
||||
@@ -64,7 +63,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
const handleImport = async (): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
void logEvent('Dashboard List V2: Import and next clicked', {});
|
||||
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
|
||||
@@ -79,15 +78,6 @@ 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();
|
||||
@@ -139,7 +129,7 @@ function ImportJsonPanel({ onClose }: Props): JSX.Element {
|
||||
prefix={<MonitorDot size={14} />}
|
||||
testId="upload-json-file"
|
||||
onClick={(): void => {
|
||||
void logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
logEvent('Dashboard List V2: Upload JSON file clicked', {});
|
||||
}}
|
||||
>
|
||||
{t('upload_json_file')}
|
||||
|
||||
@@ -168,12 +168,14 @@ describe('TracesExplorer -', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { getByText } = renderWithTracesExplorerRouter(<TracesExplorer />);
|
||||
renderWithTracesExplorerRouter(<TracesExplorer />);
|
||||
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
await screen.findByText('demo-app');
|
||||
expect(getByText('options_menu.options')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('periscope-btn-options-traces'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// the list scrolls instead of paging
|
||||
expect(screen.queryByTestId('pagination-page-size')).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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,
|
||||
@@ -9,12 +12,15 @@ import {
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { usePreferenceContext } from '../context/PreferenceContextProvider';
|
||||
import { PreferenceSourcePage } from '../types';
|
||||
import { PanelColumns, 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[];
|
||||
@@ -57,9 +63,9 @@ function Consumer({
|
||||
type="button"
|
||||
onClick={(): void => {
|
||||
const newCols: TelemetryFieldKey[] =
|
||||
sourcePage === SavedviewtypesSourceDTO.traces
|
||||
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
sourcePage === SavedviewtypesSourceDTO.logs
|
||||
? (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
|
||||
: (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
|
||||
slice.updateColumns(newCols);
|
||||
}}
|
||||
>
|
||||
@@ -436,4 +442,226 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
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,
|
||||
@@ -197,6 +200,64 @@ 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, {})]);
|
||||
|
||||
@@ -212,4 +273,21 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(mockLogsUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockTracesUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
@@ -177,6 +178,7 @@ describe('usePreferenceUpdater', () => {
|
||||
expect(mockTracesUpdateColumns).toHaveBeenCalledWith(
|
||||
newColumns,
|
||||
PreferenceMode.DIRECT,
|
||||
undefined,
|
||||
);
|
||||
expect(mockLogsUpdateColumns).not.toHaveBeenCalled();
|
||||
expect(setReSync).toHaveBeenCalledWith(true);
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { isPreferenceSourcePage } from '../utils';
|
||||
import { hasPreferences, isPreferenceSourcePage } from '../utils';
|
||||
|
||||
describe('isPreferenceSourcePage', () => {
|
||||
it.each([SavedviewtypesSourceDTO.logs, SavedviewtypesSourceDTO.traces])(
|
||||
'is true for %s',
|
||||
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',
|
||||
(source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(true);
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
SavedviewtypesSourceDTO.ai_observability,
|
||||
])('is false for %s', (source) => {
|
||||
expect(isPreferenceSourcePage(source)).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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
Reference in New Issue
Block a user