Compare commits

..

8 Commits

Author SHA1 Message Date
Abhi Kumar
f4b9c1bb2b feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
c50a90ac76 fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
db03efb495 feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
4c36a55188 feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
3bad49e8ed fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
ab4cae6fa1 fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
8b87bd7c3a feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
a0d9e9c8fd feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
84 changed files with 3506 additions and 22 deletions

View File

@@ -4305,6 +4305,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5224,6 +5230,101 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
color?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
size?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5233,7 +5334,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6159,6 +6261,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -277,6 +278,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -43,7 +43,9 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}

View File

@@ -95,6 +95,7 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],

View File

@@ -0,0 +1,45 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
TEXT: false,

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -14,6 +14,7 @@ export type PartialPanelTypes = {
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.SCATTER]: 'scatter';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
@@ -135,6 +136,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -38,6 +38,8 @@ interface ConfigPaneProps {
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Group-by labels of a joined scalar result, for the scatter dimensions editor. */
groupColumns?: string[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
@@ -64,6 +66,7 @@ function ConfigPane({
queryType,
legendSeries,
tableColumns,
groupColumns,
stepInterval,
panel,
panelId,
@@ -132,6 +135,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -158,6 +162,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,6 +12,7 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isChanged } from '../utils/changes';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -60,6 +61,7 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -71,7 +73,10 @@ function SectionSlot({
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (config.kind === SectionKind.Visualization) {
if (
config.kind === SectionKind.Visualization ||
config.kind === SectionKind.Dimensions
) {
return true;
}
const value = editor?.get(spec);
@@ -125,6 +130,8 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -0,0 +1,61 @@
.chips {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
position: relative;
display: inline-flex;
align-items: center;
max-width: 100%;
height: 32px;
padding: 0 14px;
border: 1px solid var(--l1-border);
border-radius: 999px;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: var(--font-mono, monospace);
font-size: 13px;
cursor: pointer;
transition:
border-color 150ms,
background 150ms;
&:hover {
border-color: var(--l2-border);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
color: var(--l1-foreground);
&:hover {
border-color: var(--bg-robin-500);
}
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,68 @@
import cx from 'classnames';
import styles from './ConfigChips.module.scss';
export interface ConfigChipItem<T extends string = string> {
value: T;
label: string;
}
interface ConfigChipsProps<T extends string> {
testId: string;
/** Selected values, kept in `items` order. */
value: T[];
items: ConfigChipItem<T>[];
onChange: (value: T[]) => void;
'aria-label'?: string;
}
/** Multi-choice toggle chips; any number may be on, including none. */
function ConfigChips<T extends string>({
testId,
value,
items,
onChange,
'aria-label': ariaLabel,
}: ConfigChipsProps<T>): JSX.Element {
const toggle = (item: T): void => {
const next = value.includes(item)
? value.filter((selected) => selected !== item)
: [...value, item];
onChange(
items
.map((candidate) => candidate.value)
.filter((candidate) => next.includes(candidate)),
);
};
return (
<fieldset
aria-label={ariaLabel}
data-testid={testId}
className={styles.chips}
>
{items.map((item) => {
const selected = value.includes(item.value);
return (
<label
key={item.value}
data-testid={`${testId}-${item.value}`}
className={cx(styles.chip, { [styles.selected]: selected })}
title={item.label}
>
<input
type="checkbox"
className={styles.input}
checked={selected}
aria-label={item.label}
onChange={(): void => toggle(item.value)}
/>
<span className={styles.label}>{item.label}</span>
</label>
);
})}
</fieldset>
);
}
export default ConfigChips;

View File

@@ -0,0 +1,59 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigChips from '../ConfigChips';
const ITEMS = [
{ value: 'a', label: 'alpha' },
{ value: 'b', label: 'beta' },
{ value: 'c', label: 'gamma' },
];
describe('ConfigChips', () => {
it('marks the selected chips as checked', () => {
render(
<ConfigChips
testId="chips"
value={['b']}
items={ITEMS}
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('checkbox', { name: 'beta' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'alpha' })).not.toBeChecked();
});
it('adds a chip in item order, not click order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigChips
testId="chips"
value={['c']}
items={ITEMS}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('chips-a'));
expect(onChange).toHaveBeenCalledWith(['a', 'c']);
});
it('removes a selected chip, down to none', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigChips
testId="chips"
value={['a']}
items={ITEMS}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('chips-a'));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@@ -0,0 +1,23 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// The design-system slider insets its track by half a thumb on each side so the
// fill follows the thumb's centre. Pull that inset back off the row so the track
// lines up with the other controls; the thumb never paints past the track edge.
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
}
.value {
flex-shrink: 0;
min-width: 56px;
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,49 @@
import { Slider } from '@signozhq/ui/slider';
import styles from './ConfigRangeSlider.module.scss';
interface ConfigRangeSliderProps {
testId: string;
value: [number, number];
min: number;
max: number;
step: number;
/** Renders the current range beside the track (e.g. `4–24 px`). */
formatValue?: (value: [number, number]) => string;
onChange: (value: [number, number]) => void;
}
/** Two-thumb slider for a min/max pair; the thumbs can meet but not cross. */
function ConfigRangeSlider({
testId,
value,
min,
max,
step,
formatValue,
onChange,
}: ConfigRangeSliderProps): JSX.Element {
return (
<div className={styles.row}>
<Slider
range
testId={testId}
className={styles.slider}
value={value}
min={min}
max={max}
step={step}
onChange={(next): void => {
if (Array.isArray(next) && next.length === 2) {
onChange([Math.min(next[0], next[1]), Math.max(next[0], next[1])]);
}
}}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : `${value[0]}–${value[1]}`}
</span>
</div>
);
}
export default ConfigRangeSlider;

View File

@@ -0,0 +1,51 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigRangeSlider from '../ConfigRangeSlider';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ConfigRangeSlider', () => {
it('renders a thumb per end and the formatted range', () => {
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
formatValue={([min, max]): string => `${min}–${max} px`}
onChange={jest.fn()}
/>,
);
expect(screen.getAllByRole('slider')).toHaveLength(2);
expect(screen.getByText('4–24 px')).toBeInTheDocument();
});
it('moves one end and reports the pair low to high', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
onChange={onChange}
/>,
);
const [, maxThumb] = screen.getAllByRole('slider');
maxThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
});
});

View File

@@ -88,6 +88,16 @@ export const TILE_DRAWINGS = {
<path d="M4 20 C 8 8, 20 5, 60 4" />
</TileSvg>
),
scaleAuto: (
<TileSvg tone="robin">
<path d="M4 20 L60 4" strokeDasharray="3 3" />
</TileSvg>
),
scaleSymlog: (
<TileSvg tone="robin">
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
<TileSvg tone="robin">
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />

View File

@@ -13,6 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
export interface SectionEditorContext {
legendSeries?: LegendSeries[];
tableColumns?: TableColumnOption[];
/** Group-by labels of the joined scalar result; the colour-by options. */
groupColumns?: string[];
/** Label of the value column a Scatter Plot sizes its dots by; unset when none is. */
sizeColumnLabel?: string;
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;

View File

@@ -5,6 +5,8 @@ import type {
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
@@ -25,13 +27,18 @@ import BucketsSection from './sections/BucketsSection/BucketsSection';
import { summarizeBuckets } from './sections/BucketsSection/summary';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import { summarizeChartAppearance } from './sections/ChartAppearanceSection/summary';
import { DEFAULT_POINTS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
import { summarizeDimensions } from './sections/DimensionsSection/summary';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import { DEFAULT_DECIMAL_PRECISION } from './sections/FormattingSection/options';
import { summarizeFormatting } from './sections/FormattingSection/summary';
import LegendSection from './sections/LegendSection/LegendSection';
import { summarizeLegend } from './sections/LegendSection/summary';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
import { summarizeScatterAxes } from './sections/ScatterAxesSection/summary';
import { summarizeTextLayout } from './sections/TextLayoutSection/summary';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
@@ -107,6 +114,22 @@ export const SECTION_REGISTRY: {
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeAxes,
},
// Same `axes` key as above, in Scatter Plot's per-axis shape.
[SectionKind.ScatterAxes]: {
Component: ScatterAxesSection,
get: (spec): DashboardtypesScatterPlotAxesDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeScatterAxes,
},
[SectionKind.Dimensions]: {
Component: DimensionsSection,
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
update: (spec, dimensions): PanelSpec =>
updatePluginSlice(spec, 'dimensions', dimensions),
summarize: summarizeDimensions,
},
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>
@@ -121,6 +144,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
renderedDefaults: { points: DEFAULT_POINTS },
summarize: summarizeChartAppearance,
},
[SectionKind.Visualization]: {

View File

@@ -13,6 +13,7 @@ import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import PointsFields from './PointsFields';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
@@ -37,8 +38,9 @@ function ChartAppearanceSection({
controls,
onChange,
stepInterval,
sizeColumnLabel,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const interpolation = value?.lineInterpolation;
@@ -120,6 +122,15 @@ function ChartAppearanceSection({
/>
)}
{controls.points && (
<PointsFields
value={value?.points}
defaultValue={defaultValue?.points}
sizeColumnLabel={sizeColumnLabel}
onChange={(points): void => onChange({ ...value, points })}
/>
)}
{controls.spanGaps && (
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"

View File

@@ -0,0 +1,87 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
POINT_OPACITY_BOUNDS,
POINT_SIZE_BOUNDS,
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeSlider from '../../controls/ConfigRangeSlider/ConfigRangeSlider';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import { createFieldResetter } from '../../utils/changes';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
const OPACITY_STEP = 0.01;
interface PointsFieldsProps {
value: DashboardtypesScatterPlotPointsDTO | undefined;
defaultValue: DashboardtypesScatterPlotPointsDTO | undefined;
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
/** The column dots are sized by; unset draws every dot at one size. */
sizeColumnLabel?: string;
}
function PointsFields({
value,
defaultValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
// The renderer's defaults, so a thumb starts where an unset field draws.
const size = resolvePointSize(value);
return (
<>
{sizeColumnLabel ? (
<ConfigField
label="Point size"
help={`Each dot's area scales with ${sizeColumnLabel} between min and max.`}
{...reset('minSize', 'maxSize')}
>
<ConfigRangeSlider
testId="panel-editor-v2-point-size-range"
value={[size.min, size.max]}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSizeRange}
onChange={([minSize, maxSize]): void =>
onChange({ ...value, minSize, maxSize })
}
/>
</ConfigField>
) : (
<ConfigField
label="Point size"
help="Every dot uses this size. Map a Size column to draw a bubble chart."
{...reset('size')}
>
<ConfigSlider
testId="panel-editor-v2-point-size"
value={size.fixed}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSize}
onChange={(next): void => onChange({ ...value, size: next })}
/>
</ConfigField>
)}
<ConfigField label="Fill opacity" {...reset('opacity')}>
<ConfigSlider
testId="panel-editor-v2-point-opacity"
value={resolvePointOpacity(value)}
min={POINT_OPACITY_BOUNDS.min}
max={POINT_OPACITY_BOUNDS.max}
step={OPACITY_STEP}
formatValue={formatOpacity}
onChange={(opacity): void => onChange({ ...value, opacity })}
/>
</ConfigField>
</>
);
}
export default PointsFields;

View File

@@ -0,0 +1,114 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ChartAppearanceSection from '../ChartAppearanceSection';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartAppearanceSection points', () => {
it('offers one point size when nothing sizes the dots', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ points: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-point-size')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size-range'),
).not.toBeInTheDocument();
expect(screen.getByText('6 px')).toBeInTheDocument();
expect(
screen.getByText(
'Every dot uses this size. Map a Size column to draw a bubble chart.',
),
).toBeInTheDocument();
expect(screen.getByText('70%')).toBeInTheDocument();
});
it('offers a min/max range named after the size column when one is bound', () => {
render(
<ChartAppearanceSection
value={{ points: { minSize: 5 } }}
controls={{ points: true }}
sizeColumnLabel="B.count()"
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-point-size-range'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.getByText('5–24 px')).toBeInTheDocument();
expect(
screen.getByText(
"Each dot's area scales with B.count() between min and max.",
),
).toBeInTheDocument();
});
it('steps the fixed size by a whole pixel, keeping the range', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { size: 6, minSize: 4, maxSize: 30 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const [sizeThumb] = screen.getAllByRole('slider');
sizeThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith({
points: { size: 7, minSize: 4, maxSize: 30 },
});
});
it('stops the opacity at 10%', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { opacity: 0.1 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const thumbs = screen.getAllByRole('slider');
const opacityThumb = thumbs[thumbs.length - 1];
expect(opacityThumb).toHaveAttribute('aria-valuemin', '0.1');
opacityThumb.focus();
await user.keyboard('{ArrowLeft}');
expect(onChange).not.toHaveBeenCalled();
});
it('leaves the points out of a kind that does not declare them', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.queryByText('Fill opacity')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,25 @@
import { summarizeChartAppearance } from '../summary';
describe('summarizeChartAppearance points', () => {
it('reads one size when nothing sizes the dots', () => {
expect(
summarizeChartAppearance({ points: { size: 8 } }, { points: true }, {}),
).toBe('8 px dots, 70%');
});
it('reads the range when a size column is bound', () => {
expect(
summarizeChartAppearance(
{ points: { minSize: 3, opacity: 0.5 } },
{ points: true },
{ sizeColumnLabel: 'B.count()' },
),
).toBe('3–24 px dots, 50%');
});
it('says nothing about points for a kind without them', () => {
expect(summarizeChartAppearance(undefined, { showPoints: true }, {})).toBe(
'',
);
});
});

View File

@@ -1,15 +1,33 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesScatterPlotPointsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import type {
PanelChartAppearanceSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
import { FILL_MODE_OPTIONS, LINE_INTERPOLATION_OPTIONS } from './options';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
function describePoints(
points: DashboardtypesScatterPlotPointsDTO | undefined,
sizeColumnLabel: string | undefined,
): string {
const size = resolvePointSize(points);
const dots = sizeColumnLabel
? `${formatPointSizeRange([size.min, size.max])} dots`
: `${formatPointSize(size.fixed)} dots`;
return `${dots}, ${formatOpacity(resolvePointOpacity(points))}`;
}
function describeLine(value: PanelChartAppearanceSlice): string | undefined {
const interpolation = LINE_INTERPOLATION_OPTIONS.find(
@@ -25,6 +43,7 @@ function describeLine(value: PanelChartAppearanceSlice): string | undefined {
export function summarizeChartAppearance(
value: PanelChartAppearanceSlice | undefined,
controls: SectionControlsOf<SectionKind.ChartAppearance>,
ctx: SectionEditorContext,
): string {
const appearance = value ?? {};
const fill = FILL_MODE_OPTIONS.find(
@@ -37,5 +56,6 @@ export function summarizeChartAppearance(
controls.fillMode && fill && `${fill.label} fill`,
controls.showPoints && appearance.showPoints && 'points',
controls.spanGaps && appearance.spanGaps?.fillOnlyBelow && 'breaks gaps',
controls.points && describePoints(appearance.points, ctx.sizeColumnLabel),
]);
}

View File

@@ -12,3 +12,11 @@ export function defaultDisconnectDuration(stepInterval?: number): string {
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
export function formatPointSize(size: number): string {
return `${size} px`;
}
export function formatPointSizeRange([min, max]: [number, number]): string {
return `${min}–${max} px`;
}

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigChips from '../../controls/ConfigChips/ConfigChips';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import {
buildColorKeyItems,
buildDimensionItems,
getColorByHelp,
UNSET_DIMENSION,
} from './utils';
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
const VALUE_FIELDS = [
{
dimension: 'x',
label: 'X axis',
help: 'The value that places each dot left to right.',
unsetLabel: 'Auto: first value',
},
{
dimension: 'y',
label: 'Y axis',
help: 'The value that places each dot bottom to top.',
unsetLabel: 'Auto: second value',
},
{
dimension: 'size',
label: 'Size',
help:
'Optional. Bigger values draw bigger dots; a group without one draws at the default size.',
unsetLabel: 'None',
},
] as const;
/**
* Edits the `dimensions` slice of a Scatter Plot: which value column each axis
* and the dot size read, and which group-by keys colour the dots. Options come
* from the preview query's joined result.
*/
function DimensionsSection({
value,
defaultValue,
onChange,
tableColumns = [],
groupColumns = [],
}: DimensionsSectionProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const valueItems = useMemo(
() =>
tableColumns.map((column) => ({ value: column.key, label: column.label })),
[tableColumns],
);
const color = value?.color ?? [];
return (
<>
{VALUE_FIELDS.map(({ dimension, label, help, unsetLabel }) => (
<ConfigField
key={dimension}
label={label}
help={help}
{...reset(dimension)}
>
<ConfigSelect
testId={`panel-editor-v2-dimension-${dimension}`}
value={value?.[dimension] ?? UNSET_DIMENSION}
items={buildDimensionItems(valueItems, value?.[dimension], unsetLabel)}
onChange={(next): void => onChange({ ...value, [dimension]: next })}
/>
</ConfigField>
))}
<ConfigField
label="Colour by"
help={getColorByHelp(color, groupColumns)}
{...reset('color')}
>
<ConfigChips
testId="panel-editor-v2-dimension-color"
aria-label="Colour by"
value={color}
items={buildColorKeyItems(groupColumns, color)}
onChange={(next): void => onChange({ ...value, color: next })}
/>
</ConfigField>
</>
);
}
export default DimensionsSection;

View File

@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DimensionsSection from '../DimensionsSection';
const TABLE_COLUMNS = [
{ key: 'A.count()', label: 'count()' },
{ key: 'A.p99(duration_nano)', label: 'p99(duration_nano)' },
];
async function pick(dimension: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(`panel-editor-v2-dimension-${dimension}`);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('DimensionsSection', () => {
it('shows each unset dimension as its default', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
groupColumns={['service.name']}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'Auto: first value',
);
expect(
screen.getByTestId('panel-editor-v2-dimension-size'),
).toHaveTextContent('None');
expect(
screen.getByText('None selected: colour by every group key.'),
).toBeInTheDocument();
});
it('binds an axis to a value column by its key', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ x: 'A.count()' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('y', 'p99(duration_nano)');
expect(onChange).toHaveBeenCalledWith({
x: 'A.count()',
y: 'A.p99(duration_nano)',
});
});
it('colours by any combination of group-by keys, kept in result order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const { rerender } = render(
<DimensionsSection
value={undefined}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
groupColumns={['k8s.namespace.name', 'k8s.pod.name']}
/>,
);
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-k8s.pod.name'),
);
expect(onChange).toHaveBeenLastCalledWith({ color: ['k8s.pod.name'] });
rerender(
<DimensionsSection
value={{ color: ['k8s.pod.name'] }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
groupColumns={['k8s.namespace.name', 'k8s.pod.name']}
/>,
);
expect(
screen.getByText('One colour per value of the selected keys.'),
).toBeInTheDocument();
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-k8s.namespace.name'),
);
expect(onChange).toHaveBeenLastCalledWith({
color: ['k8s.namespace.name', 'k8s.pod.name'],
});
});
it('unselects the last colour key back to every key', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DimensionsSection
value={{ color: ['service.name'] }}
onChange={onChange}
groupColumns={['service.name']}
/>,
);
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-service.name'),
);
expect(onChange).toHaveBeenCalledWith({ color: [] });
});
it('keeps a selected colour key the results no longer have', () => {
render(
<DimensionsSection
value={{ color: ['host.name'] }}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-dimension-color-host.name'),
).toHaveTextContent('host.name (not in results)');
});
it('asks for a group by when there is nothing to colour by', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
expect(
screen.getByText('Group the query by a label to colour dots by it.'),
).toBeInTheDocument();
});
it('clears the size back to none', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ size: 'A.count()' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('size', 'None');
expect(onChange).toHaveBeenCalledWith({ size: '' });
});
it('keeps a binding the results no longer have visible', () => {
render(
<DimensionsSection
value={{ x: 'B' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'B (not in results)',
);
});
});

View File

@@ -0,0 +1,23 @@
import type { DashboardtypesScatterPlotDimensionsDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
export function summarizeDimensions(
value: DashboardtypesScatterPlotDimensionsDTO | undefined,
_controls: unknown,
{ tableColumns = [] }: SectionEditorContext,
): string {
const labelOf = (key: string | undefined): string | undefined =>
key
? (tableColumns.find((column) => column.key === key)?.label ?? key)
: undefined;
const x = labelOf(value?.x);
const y = labelOf(value?.y);
const size = labelOf(value?.size);
return joinSummary([
x || y ? `${x ?? 'auto'} vs ${y ?? 'auto'}` : 'Auto',
size && `sized by ${size}`,
(value?.color?.length ?? 0) > 0 && `coloured by ${value?.color?.join(', ')}`,
]);
}

View File

@@ -0,0 +1,46 @@
import type { ConfigChipItem } from '../../controls/ConfigChips/ConfigChips';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
/** The select value for an unset dimension; the spec stores it as `''`. */
export const UNSET_DIMENSION = '';
/**
* The unset option first, then the result's columns. A bound key the current
* result no longer has stays listed, so the select never shows a bare value.
*/
export function buildDimensionItems(
columns: ConfigSelectItem[],
current: string | undefined,
unsetLabel: string,
): ConfigSelectItem[] {
const items = [{ value: UNSET_DIMENSION, label: unsetLabel }, ...columns];
if (current && !columns.some((column) => column.value === current)) {
items.push({ value: current, label: `${current} (not in results)` });
}
return items;
}
/** The result's group-by keys, then any selected key the result no longer has. */
export function buildColorKeyItems(
groupColumns: string[],
selected: string[],
): ConfigChipItem[] {
return [
...groupColumns.map((column) => ({ value: column, label: column })),
...selected
.filter((key) => !groupColumns.includes(key))
.map((key) => ({ value: key, label: `${key} (not in results)` })),
];
}
export function getColorByHelp(
selected: string[],
groupColumns: string[],
): string {
if (groupColumns.length === 0 && selected.length === 0) {
return 'Group the query by a label to colour dots by it.';
}
return selected.length > 0
? 'One colour per value of the selected keys.'
: 'None selected: colour by every group key.';
}

View File

@@ -0,0 +1,32 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ScatterAxisFields from './ScatterAxisFields';
/** Edits a Scatter Plot's `axes` slice: soft bounds and scale, per axis. */
function ScatterAxesSection({
value,
defaultValue,
onChange,
}: SectionEditorProps<SectionKind.ScatterAxes>): JSX.Element {
return (
<>
<ScatterAxisFields
axis="x"
value={value?.x}
defaultValue={defaultValue?.x}
onChange={(x): void => onChange({ ...value, x })}
/>
<ScatterAxisFields
axis="y"
value={value?.y}
defaultValue={defaultValue?.y}
onChange={(y): void => onChange({ ...value, y })}
/>
</>
);
}
export default ScatterAxesSection;

View File

@@ -0,0 +1,84 @@
import { Input } from 'antd';
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import { SCALE_HELP, SCALE_OPTIONS } from './options';
interface ScatterAxisFieldsProps {
axis: 'x' | 'y';
value: DashboardtypesScatterPlotAxisDTO | undefined;
defaultValue: DashboardtypesScatterPlotAxisDTO | undefined;
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
}
function ScatterAxisFields({
axis,
value,
defaultValue,
onChange,
}: ScatterAxisFieldsProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const name = axis.toUpperCase();
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
return (
<>
<ConfigField
label={`${name}-axis label`}
help="Drawn along the axis, and names its value in the tooltip."
{...reset('label')}
>
<Input
data-testid={`panel-editor-v2-${axis}-label`}
placeholder="None"
value={value?.label ?? ''}
onChange={(event): void =>
onChange({ ...value, label: event.target.value })
}
/>
</ConfigField>
<ConfigField
label={`${name}-axis range`}
help="The axis always shows at least this range. Data outside it still stretches the axis."
{...reset('softMin', 'softMax')}
>
<ConfigFieldRow>
<ConfigNumberInput
testId={`panel-editor-v2-${axis}-soft-min`}
label="Min"
value={value?.softMin}
onChange={(softMin): void => onChange({ ...value, softMin })}
/>
<ConfigNumberInput
testId={`panel-editor-v2-${axis}-soft-max`}
label="Max"
value={value?.softMax}
onChange={(softMax): void => onChange({ ...value, softMax })}
/>
</ConfigFieldRow>
</ConfigField>
<ConfigField
label={`${name}-axis scale`}
help={SCALE_HELP[scale]}
{...reset('scale')}
>
<ConfigTiles
testId={`panel-editor-v2-${axis}-scale`}
aria-label={`${name}-axis scale`}
value={scale}
items={SCALE_OPTIONS}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
</ConfigField>
</>
);
}
export default ScatterAxisFields;

View File

@@ -0,0 +1,94 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import ScatterAxesSection from '../ScatterAxesSection';
describe('ScatterAxesSection', () => {
it('renders bounds and scale for both axes', () => {
render(<ScatterAxesSection value={undefined} onChange={jest.fn()} />);
['x', 'y'].forEach((axis) => {
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-min`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-max`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-scale`),
).toBeInTheDocument();
});
});
it('writes a soft bound to its own axis', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ x: { scale: DashboardtypesAxisScaleDTO.log } }}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('panel-editor-v2-y-soft-max'), '5');
expect(onChange).toHaveBeenCalledWith({
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { softMax: 5 },
});
});
it('writes an axis label', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.type(screen.getByTestId('panel-editor-v2-y-label'), 'p');
expect(onChange).toHaveBeenCalledWith({ y: { label: 'p' } });
});
it('clears a soft bound to null when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection value={{ x: { softMin: 10 } }} onChange={onChange} />,
);
await user.clear(screen.getByTestId('panel-editor-v2-x-soft-min'));
expect(onChange).toHaveBeenCalledWith({ x: { softMin: null } });
});
it.each([
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
])('sets the x scale to %s', async (scale) => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.click(screen.getByTestId(`panel-editor-v2-x-scale-${scale}`));
expect(onChange).toHaveBeenCalledWith({ x: { scale } });
});
it('marks a changed scale against the default and resets it', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
defaultValue={{ y: { scale: DashboardtypesAxisScaleDTO.auto } }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole('button', { name: /reset/i }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesAxisScaleDTO.auto },
});
});
});

View File

@@ -0,0 +1,37 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const SCALE_OPTIONS: ConfigTileItem<DashboardtypesAxisScaleDTO>[] = [
{
value: DashboardtypesAxisScaleDTO.auto,
label: 'Auto',
drawing: TILE_DRAWINGS.scaleAuto,
},
{
value: DashboardtypesAxisScaleDTO.linear,
label: 'Linear',
drawing: TILE_DRAWINGS.scaleLinear,
},
{
value: DashboardtypesAxisScaleDTO.log,
label: 'Logarithmic',
drawing: TILE_DRAWINGS.scaleLog,
},
{
value: DashboardtypesAxisScaleDTO.symlog,
label: 'Symlog',
drawing: TILE_DRAWINGS.scaleSymlog,
},
];
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
[DashboardtypesAxisScaleDTO.auto]:
'Logarithmic when every value is positive and they span three or more orders of magnitude, otherwise linear.',
[DashboardtypesAxisScaleDTO.linear]: 'Evenly spaced values.',
[DashboardtypesAxisScaleDTO.log]:
"Spreads out values that span orders of magnitude. Groups with a value of 0 or less can't be placed and are left out.",
[DashboardtypesAxisScaleDTO.symlog]:
'Logarithmic, but places zero and negative values too.',
};

View File

@@ -0,0 +1,24 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { joinSummary } from '../../utils/summary';
import { SCALE_OPTIONS } from './options';
function scaleLabel(scale: DashboardtypesAxisScaleDTO | undefined): string {
return (
SCALE_OPTIONS.find(
(option) => option.value === (scale ?? DashboardtypesAxisScaleDTO.auto),
)?.label ?? ''
);
}
export function summarizeScatterAxes(
value: DashboardtypesScatterPlotAxesDTO | undefined,
): string {
return joinSummary([
`X ${scaleLabel(value?.x?.scale)}`,
`Y ${scaleLabel(value?.y?.scale)}`,
]);
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getSizeColumnLabel } from '../sizeColumnLabel';
const scatterSpec = (size?: string): DashboardtypesPanelSpecDTO =>
({
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions: { size } } },
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
const COLUMNS = [{ key: 'B', label: 'B.count()' }];
describe('getSizeColumnLabel', () => {
it('names the bound column by its label', () => {
expect(getSizeColumnLabel(scatterSpec('B'), COLUMNS)).toBe('B.count()');
});
it('is unset when nothing is bound', () => {
expect(getSizeColumnLabel(scatterSpec(''), COLUMNS)).toBeUndefined();
expect(getSizeColumnLabel(scatterSpec(), COLUMNS)).toBeUndefined();
});
it('is unset for a bound key the loaded result no longer has, as the chart draws it', () => {
expect(getSizeColumnLabel(scatterSpec('C'), COLUMNS)).toBeUndefined();
});
it('falls back to the key before the result loads', () => {
expect(getSizeColumnLabel(scatterSpec('B'), [])).toBe('B');
});
it('is unset for other kinds', () => {
const spec = {
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
expect(getSizeColumnLabel(spec, COLUMNS)).toBeUndefined();
});
});

View File

@@ -0,0 +1,24 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnOption } from '../../hooks/useTableColumns';
/**
* What the dots are sized by, mirroring the renderer: a bound key the loaded
* result no longer has sizes nothing. Before the result loads the key stands in.
*/
export function getSizeColumnLabel(
spec: DashboardtypesPanelSpecDTO,
tableColumns: TableColumnOption[] = [],
): string | undefined {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return undefined;
}
const key = spec.plugin.spec.dimensions?.size;
if (!key) {
return undefined;
}
if (tableColumns.length === 0) {
return key;
}
return tableColumns.find((column) => column.key === key)?.label;
}

View File

@@ -35,6 +35,7 @@ import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useGroupColumns } from './hooks/useGroupColumns';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
@@ -196,6 +197,7 @@ function QueryEditorBody({
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
const groupColumns = useGroupColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
@@ -325,6 +327,7 @@ function QueryEditorBody({
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useGroupColumns } from '../useGroupColumns';
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn() }),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData',
() => ({ getScalarResults: jest.fn(() => []) }),
);
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const DATA = {
response: undefined,
legendMap: {},
requestPayload: undefined,
} as unknown as PanelQueryData;
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind, spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
}
describe('useGroupColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns [] for a kind that does not join its scalar rows', () => {
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/PieChartPanel'), DATA),
);
expect(result.current).toStrictEqual([]);
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('keeps only the group-by columns, by key', () => {
mockPrepareScalarTables.mockReturnValue([
{ columns: [], rows: [] },
{
columns: [
{
id: 'k8s.namespace.name',
name: 'k8s.namespace.name',
isValueColumn: false,
},
{ id: 'A', name: 'cpu', isValueColumn: true },
{ id: 'k8s.pod.name', name: 'k8s.pod.name', isValueColumn: false },
],
rows: [],
},
]);
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/ScatterPlotPanel'), DATA),
);
expect(result.current).toStrictEqual(['k8s.namespace.name', 'k8s.pod.name']);
});
});

View File

@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -47,6 +48,7 @@ const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
@@ -60,6 +62,14 @@ const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const scatterPanel = (color?: string[]): DashboardtypesPanelDTO =>
({
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions: { color } } },
queries: [],
},
}) as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -133,6 +143,36 @@ describe('useLegendSeries', () => {
expect(mockFlatten).not.toHaveBeenCalled();
});
it('resolves scatter plot panels by the colour-by label', () => {
mockPrepareScalarTables.mockReturnValue([
{
queryName: 'A',
legend: '',
columns: [
{ id: 'ns', name: 'ns', queryName: '', isValueColumn: false },
{ id: 'pod', name: 'pod', queryName: '', isValueColumn: false },
{ id: 'A', name: 'A', queryName: 'A', isValueColumn: true },
{ id: 'B', name: 'B', queryName: 'B', isValueColumn: true },
],
rows: [
{ data: { ns: 'prod', pod: 'p1', A: 1, B: 2 } },
{ data: { ns: 'dev', pod: 'p2', A: 3, B: 4 } },
{ data: { ns: 'prod', pod: 'p3', A: 5, B: 6 } },
],
},
]);
const { result } = renderHook(() =>
useLegendSeries(scatterPanel(['ns']), DATA),
);
expect(result.current).toStrictEqual([
{ label: 'prod', defaultColor: 'color:prod' },
{ label: 'dev', defaultColor: 'color:dev' },
]);
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -56,6 +56,23 @@ describe('useTableColumns', () => {
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('resolves columns for a scatter plot, which joins its rows like a table', () => {
tableWith([{ id: 'A', name: 'p99', isValueColumn: true }]);
const panel = {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ScatterPlotPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useTableColumns(panel, DATA));
expect(result.current).toStrictEqual([
{ key: 'A', label: 'p99', unit: undefined },
]);
});
it('returns [] when there is no scalar table with columns', () => {
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
/**
* Group-by labels of a joined scalar result, the keys a group column is stored
* under. Empty for kinds that don't join their scalar rows or before data arrives.
*/
export function useGroupColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): string[] {
return useMemo(() => {
if (
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
.formatTableResultForUI
) {
return [];
}
const table = prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0);
return (table?.columns ?? [])
.filter((column) => !column.isValueColumn)
.map((column) => column.id);
}, [
panel.spec.plugin.kind,
data.response,
data.legendMap,
data.requestPayload,
]);
}

View File

@@ -21,7 +21,12 @@ export function useLegendSeries(
return useMemo(() => {
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
return resolve
? resolve({ queries: panel.spec.queries, data, isDarkMode })
? resolve({
spec: panel.spec,
queries: panel.spec.queries,
data,
isDarkMode,
})
: [];
}, [kind, panel.spec.queries, data, isDarkMode]);
}, [kind, panel.spec, data, isDarkMode]);
}

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelFormattingSlice } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getColumnUnit } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getColumnUnit';
import { getValueColumnLabels } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getValueColumnLabels';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -28,14 +30,18 @@ export interface TableColumnOption {
* Resolves a Table panel's value (aggregation) columns into `{ key, label }`
* options, so the table-only config editors (column units, per-column thresholds)
* store the query-keyed value the renderer looks up by while showing the readable
* column name. Empty for non-table kinds or before data arrives.
* column name. Empty for kinds that don't join their scalar rows (Table and
* Scatter Plot do) or before data arrives.
*/
export function useTableColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): TableColumnOption[] {
return useMemo(() => {
if (panel.spec.plugin.kind !== 'signoz/TablePanel') {
if (
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
.formatTableResultForUI
) {
return [];
}
const table = prepareScalarTables({
@@ -49,13 +55,14 @@ export function useTableColumns(
const columnUnits =
(panel.spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting
?.columnUnits ?? {};
const labels = getValueColumnLabels(table.columns);
return table.columns
.filter((column) => column.isValueColumn)
.map((column) => {
const key = column.id || column.name;
return {
key,
label: column.name,
label: labels[key] ?? column.name,
unit: getColumnUnit(key, columnUnits),
};
});

View File

@@ -33,6 +33,7 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
// Static kind: no query surface at all.
'signoz/TextPanel': [],
};
@@ -48,6 +49,7 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
'signoz/TextPanel': [],
'signoz/ScatterPlotPanel': [metrics, logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
@@ -100,7 +102,7 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
// Only Table asks the server to transpose its scalar result into UI rows.
// Only Table and Scatter Plot ask the server to join their scalar results into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
@@ -108,6 +110,13 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/ScatterPlotPanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,

View File

@@ -0,0 +1,5 @@
.chart {
flex: 1;
min-width: 0;
min-height: 0;
}

View File

@@ -0,0 +1,232 @@
import { useCallback, useMemo, useRef } from 'react';
import { ChartScatter } from '@signozhq/icons';
import type { DashboardtypesScatterPlotPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import type { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { ScatterPointClick } from 'lib/visualization/charts/types';
import Scatter from 'lib/visualization/charts/Scatter/Scatter';
import {
buildScatterConfig,
prepareScatterChartData,
} from 'lib/visualization/charts/Scatter/utils';
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import NoData from '../../components/NoData/NoData';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import PanelStyles from '../../panel.module.scss';
import type { PanelRendererProps } from '../../types/rendererProps';
import { mapThresholds } from '../../utils/baseConfigBuilder';
import { enrichScatterClick } from '../../utils/drilldown/enrichScatterClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import {
resolveSelectionPreferencesSource,
shouldSaveSelectionPreference,
} from '../../utils/selectionPreferences';
import ScatterPlotFooter from './components/ScatterPlotFooter/ScatterPlotFooter';
import {
getScatterPlotEmptyMessage,
getScatterPlotFooterText,
} from './messages';
import { resolvePointOpacity, resolvePointSize } from './points';
import { prepareScatterPlotData } from './prepareData';
import styles from './Renderer.module.scss';
import { ScatterPlotDataStatus } from './types';
import { toScatterAxisOptions } from './utils';
function ScatterPlotPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
panelMode,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/ScatterPlotPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const spec = useMemo<DashboardtypesScatterPlotPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
// V5 joins every query into one scalar result, so the first non-empty table
// holds every group.
const table = useMemo(
() =>
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0),
[data.response, data.legendMap, data.requestPayload],
);
const scatterData = useMemo(
() =>
prepareScatterPlotData({
table,
dimensions: spec.dimensions,
axes: spec.axes,
columnUnits: spec.formatting?.columnUnits ?? {},
}),
[table, spec.dimensions, spec.axes, spec.formatting?.columnUnits],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const emptyMessage = getScatterPlotEmptyMessage(scatterData);
const footerText = getScatterPlotFooterText(scatterData);
const readyData =
scatterData.status === ScatterPlotDataStatus.Ready &&
scatterData.drawnGroups > 0
? scatterData
: undefined;
const config = useMemo(
() =>
readyData
? buildScatterConfig({
id: panelId,
series: readyData.series,
isDarkMode,
x: toScatterAxisOptions(spec.axes?.x, readyData.channels.x.unit),
y: toScatterAxisOptions(spec.axes?.y, readyData.channels.y.unit),
pointSize: resolvePointSize(spec.chartAppearance?.points),
fillOpacity: resolvePointOpacity(spec.chartAppearance?.points),
colorMapping: spec.legend?.customColors ?? {},
thresholds: mapThresholds(spec.thresholds),
decimalPrecision,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
shouldSaveSelectionPreference: shouldSaveSelectionPreference(panelMode),
})
: undefined,
[
readyData,
panelId,
isDarkMode,
spec.axes?.x,
spec.axes?.y,
spec.chartAppearance?.points,
spec.legend?.customColors,
spec.thresholds,
decimalPrecision,
panelMode,
],
);
const chartData = useMemo(
() => (readyData ? prepareScatterChartData(readyData.series) : undefined),
[readyData],
);
const resolvePointLabels = useCallback(
(seriesIndex: number, dataIndex: number): ScatterPointLabel[] =>
readyData?.pointLabels[seriesIndex - 1]?.[dataIndex] ?? [],
[readyData],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const handlePointClick = useCallback(
({ seriesIndex, dataIndex, color, coordinates }: ScatterPointClick): void => {
if (!onClick || !readyData) {
return;
}
const payload = enrichScatterClick({
labels: readyData.pointLabels[seriesIndex - 1]?.[dataIndex] ?? [],
label: readyData.series[seriesIndex - 1]?.label ?? '',
color,
axisQueries: readyData.axisQueries,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, readyData, builderQueries, data.requestPayload],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={!!enableDrillDown}
/>
),
[panelId, enableDrillDown],
);
return (
<div
data-testid="scatter-plot-panel-renderer"
className={PanelStyles.panelContainer}
>
{!readyData && emptyMessage && (
<PanelMessage
icon={<ChartScatter size={18} />}
title={emptyMessage.title}
description={emptyMessage.description}
data-testid="scatter-plot-empty-message"
/>
)}
{!readyData && !emptyMessage && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{readyData && config && chartData && (
<>
<div ref={graphRef} className={styles.chart}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Scatter
key={panelId}
config={config}
data={chartData}
channels={readyData.channels}
resolvePointLabels={resolvePointLabels}
legendConfig={{ position: legendPosition }}
decimalPrecision={decimalPrecision}
canPinTooltip
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onPointClick={enableDrillDown ? handlePointClick : undefined}
data-testid="scatter-plot-chart"
/>
)}
</div>
{footerText && <ScatterPlotFooter text={footerText} />}
</>
)}
</div>
);
}
export default ScatterPlotPanelRenderer;

View File

@@ -0,0 +1,126 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesScatterPlotPanelSpecDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import type { BaseRendererProps } from '../../../types/rendererProps';
import BaseScatterPlotPanelRenderer from '../Renderer';
const ScatterPlotPanelRenderer =
BaseScatterPlotPanelRenderer as React.FC<BaseRendererProps>;
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function panelWith(
spec: DashboardtypesScatterPlotPanelSpecDTO,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/ScatterPlotPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
}
/** A joined scalar result: one group column plus one value column per query. */
function dataWith(queryNames: string[], rows: unknown[][]): PanelQueryData {
return {
response: {
status: 'success',
data: {
type: 'scalar',
data: {
results: [
{
queryName: queryNames[0],
columns: [
{ name: 'service.name', queryName: '', columnType: 'group' },
...queryNames.map((queryName) => ({
name: '__result_0',
queryName,
columnType: 'aggregation',
aggregationIndex: 0,
})),
],
data: rows,
},
],
},
},
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
}
function renderPanel(data: PanelQueryData): ReturnType<typeof render> {
return render(
<ScatterPlotPanelRenderer
panelId="panel-1"
panel={panelWith({})}
data={data}
isFetching={false}
error={null}
panelMode={PanelMode.DASHBOARD_VIEW}
/>,
);
}
describe('ScatterPlotPanelRenderer', () => {
it('shows the plain empty state when the query returns no groups', () => {
const { getByTestId } = renderPanel(dataWith(['A', 'B'], []));
expect(getByTestId('panel-no-data')).toBeInTheDocument();
});
it('asks for a second value when the query has one value column', () => {
const { getByTestId, getByText } = renderPanel(
dataWith(['A'], [['cart', 120]]),
);
expect(getByTestId('scatter-plot-empty-message')).toBeInTheDocument();
expect(getByText('Add a second aggregation or query')).toBeInTheDocument();
});
it('explains the group by mismatch when no row has both an x and a y', () => {
const { getByText } = renderPanel(
dataWith(['A', 'B'], [['checkout', 45, 'n/a']]),
);
expect(getByText('0 of 1 group plotted')).toBeInTheDocument();
});
it('plots once two value columns line up for a group, and says how many did', () => {
const { getByTestId, queryByTestId } = renderPanel(
dataWith(
['A', 'B'],
[
['cart', 120, 340],
['checkout', 45, 'n/a'],
],
),
);
expect(getByTestId('scatter-plot-panel-renderer')).toBeInTheDocument();
expect(queryByTestId('panel-no-data')).not.toBeInTheDocument();
expect(getByTestId('scatter-plot-footer')).toHaveTextContent(
'Showing 1 of 2 groups',
);
});
it('has no footer when every group plots', () => {
const { queryByTestId } = renderPanel(
dataWith(['A', 'B'], [['cart', 120, 340]]),
);
expect(queryByTestId('scatter-plot-footer')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,109 @@
import {
getScatterPlotEmptyMessage,
getScatterPlotFooterText,
} from '../messages';
import { type ScatterPlotData, ScatterPlotDataStatus } from '../types';
function ready(counts: {
totalGroups: number;
drawnGroups: number;
missingValueGroups?: number;
nonPositiveOnLogGroups?: number;
}): ScatterPlotData {
return {
status: ScatterPlotDataStatus.Ready,
series: [],
pointLabels: [],
channels: { x: { label: 'x' }, y: { label: 'y' } },
axisQueries: { x: 'A', y: 'A' },
missingValueGroups: 0,
nonPositiveOnLogGroups: 0,
...counts,
};
}
describe('getScatterPlotEmptyMessage', () => {
it('leaves an empty result to the plain no-data state', () => {
expect(
getScatterPlotEmptyMessage({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 0,
}),
).toBeUndefined();
expect(
getScatterPlotEmptyMessage(ready({ totalGroups: 0, drawnGroups: 0 })),
).toBeUndefined();
});
it('asks for a second value', () => {
expect(
getScatterPlotEmptyMessage({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 3,
})?.title,
).toBe('Add a second aggregation or query');
});
it('says nothing once a group plots', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 3, drawnGroups: 1, missingValueGroups: 2 }),
),
).toBeUndefined();
});
it('points at the group by when no group has both values', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 52, drawnGroups: 0, missingValueGroups: 52 }),
),
).toStrictEqual({
title: '0 of 52 groups plotted',
description:
'No group has both an X and a Y value. Group every query by exactly the same labels; a group missing from one query has no dot.',
});
});
it('points at symlog when a log axis hid every group', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 1, drawnGroups: 0, nonPositiveOnLogGroups: 1 }),
),
).toStrictEqual({
title: '0 of 1 group plotted',
description:
"1 group with a value ≤ 0 can't go on a log axis. Switch the axis to symlog to show them.",
});
});
});
describe('getScatterPlotFooterText', () => {
it('is absent when every group plots', () => {
expect(
getScatterPlotFooterText(ready({ totalGroups: 4, drawnGroups: 4 })),
).toBeUndefined();
});
it('counts the groups shown', () => {
expect(
getScatterPlotFooterText(
ready({ totalGroups: 52, drawnGroups: 37, missingValueGroups: 15 }),
),
).toBe('Showing 37 of 52 groups');
});
it('calls out the groups a log axis hid', () => {
expect(
getScatterPlotFooterText(
ready({
totalGroups: 52,
drawnGroups: 37,
missingValueGroups: 12,
nonPositiveOnLogGroups: 3,
}),
),
).toBe(
"Showing 37 of 52 groups · 3 groups with a value ≤ 0 can't go on a log axis",
);
});
});

View File

@@ -0,0 +1,23 @@
import {
DEFAULT_POINTS,
resolvePointOpacity,
resolvePointSize,
} from '../points';
describe('scatter plot points', () => {
it('draws unset points at the defaults', () => {
expect(resolvePointSize(undefined)).toStrictEqual({
fixed: DEFAULT_POINTS.size,
min: DEFAULT_POINTS.minSize,
max: DEFAULT_POINTS.maxSize,
});
expect(resolvePointOpacity(undefined)).toBe(DEFAULT_POINTS.opacity);
});
it('takes each set field and defaults the rest, null included', () => {
expect(
resolvePointSize({ size: 10, minSize: null, maxSize: 30 }),
).toStrictEqual({ fixed: 10, min: DEFAULT_POINTS.minSize, max: 30 });
expect(resolvePointOpacity({ opacity: 0.1 })).toBe(0.1);
});
});

View File

@@ -0,0 +1,405 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
PanelTable,
PanelTableColumn,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { prepareScatterPlotData, UNGROUPED_SERIES_LABEL } from '../prepareData';
import { ScatterPlotDataStatus } from '../types';
const group = (id: string): PanelTableColumn => ({
id,
name: id,
queryName: '',
isValueColumn: false,
});
const value = (id: string, name = id): PanelTableColumn => ({
id,
name,
queryName: id.split('.')[0],
isValueColumn: true,
});
function makeTable(
columns: PanelTableColumn[],
rows: Record<string, unknown>[],
): PanelTable {
return {
queryName: 'A',
legend: '',
columns,
rows: rows.map((data) => ({ data })),
};
}
const tracesTable = makeTable(
[
group('service.name'),
value('A.count()', 'count()'),
value('A.p99(duration_nano)', 'p99(duration_nano)'),
value('A.countIf(has_error = true)', 'errors'),
],
[
{
'service.name': 'cart',
'A.count()': 12000,
'A.p99(duration_nano)': 340,
'A.countIf(has_error = true)': 15,
},
{
'service.name': 'payment',
'A.count()': 8500,
'A.p99(duration_nano)': 900,
'A.countIf(has_error = true)': 420,
},
],
);
describe('prepareScatterPlotData', () => {
it('plots the first two value columns when no dimensions are set', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: undefined,
columnUnits: {},
});
expect(result.status).toBe(ScatterPlotDataStatus.Ready);
if (result.status !== ScatterPlotDataStatus.Ready) {
return;
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size).toBeUndefined();
expect(result.series).toStrictEqual([
{ label: 'cart', xs: [12000], ys: [340] },
{ label: 'payment', xs: [8500], ys: [900] },
]);
expect(result.pointLabels).toStrictEqual([
[[{ key: 'service.name', value: 'cart' }]],
[[{ key: 'service.name', value: 'payment' }]],
]);
});
it('binds x, y and size to the configured columns, with their units', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: {
x: 'A.p99(duration_nano)',
y: 'A.count()',
size: 'A.countIf(has_error = true)',
},
columnUnits: { 'A.p99(duration_nano)': 'ns' },
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels).toStrictEqual({
x: { label: 'A.p99(duration_nano)', unit: 'ns' },
y: { label: 'A.count()', unit: undefined },
size: { label: 'A.errors', unit: undefined },
});
expect(result.series[1]).toStrictEqual({
label: 'payment',
xs: [900],
ys: [8500],
sizes: [420],
});
});
it('falls back to the defaults when a dimension names a column that is gone', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: { x: 'B', y: 'A.avg(duration_nano)', size: 'C' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size).toBeUndefined();
});
it('labels each value column with its query, so shared names read apart', () => {
const result = prepareScatterPlotData({
table: makeTable(
[
group('service.name'),
value('A.count()', 'count()'),
value('A.p99(duration_nano)', 'p99(duration_nano)'),
value('B', 'count()'),
],
[
{
'service.name': 'cart',
'A.count()': 1,
'A.p99(duration_nano)': 2,
B: 3,
},
],
),
dimensions: { size: 'B' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size?.label).toBe('B.count()');
expect(result.axisQueries).toStrictEqual({ x: 'A', y: 'A' });
});
it('names an axis channel by its axis label when one is set', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: undefined,
axes: { x: { label: 'Throughput' }, y: { label: '' } },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('Throughput');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
});
it('picks a y column other than the bound x', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: { x: 'A.count()' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
});
it('colours by the configured group label and keeps every label for the tooltip', () => {
const table = makeTable(
[group('k8s.namespace.name'), group('k8s.pod.name'), value('A'), value('B')],
[
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p1', A: 1, B: 2 },
{ 'k8s.namespace.name': 'dev', 'k8s.pod.name': 'p2', A: 3, B: 4 },
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p3', A: 5, B: 6 },
],
);
const result = prepareScatterPlotData({
table,
dimensions: { color: ['k8s.namespace.name'] },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual([
'prod',
'dev',
]);
expect(result.series[0]).toMatchObject({ xs: [1, 5], ys: [2, 6] });
expect(result.pointLabels[0][1]).toStrictEqual([
{ key: 'k8s.namespace.name', value: 'prod' },
{ key: 'k8s.pod.name', value: 'p3' },
]);
});
describe('colour keys', () => {
const table = makeTable(
[group('k8s.namespace.name'), group('k8s.pod.name'), value('A'), value('B')],
[
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p1', A: 1, B: 2 },
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p2', A: 3, B: 4 },
],
);
const seriesLabels = (color: string[] | null | undefined): string[] => {
const result = prepareScatterPlotData({
table,
dimensions: { color },
columnUnits: {},
});
return result.status === ScatterPlotDataStatus.Ready
? result.series.map((series) => series.label)
: [];
};
it.each([
['none selected', []],
['an unset field', undefined],
['null from the wire', null],
])('colours by every group key with %s', (_, color) => {
expect(seriesLabels(color)).toStrictEqual(['prod, p1', 'prod, p2']);
});
it('colours by the combination of the selected keys, in result order', () => {
expect(seriesLabels(['k8s.pod.name', 'k8s.namespace.name'])).toStrictEqual([
'prod, p1',
'prod, p2',
]);
});
it('colours by one key alone', () => {
expect(seriesLabels(['k8s.namespace.name'])).toStrictEqual(['prod']);
});
it('ignores keys the result does not have', () => {
expect(seriesLabels(['host.name', 'k8s.pod.name'])).toStrictEqual([
'p1',
'p2',
]);
expect(seriesLabels(['host.name'])).toStrictEqual(['prod, p1', 'prod, p2']);
});
});
it('draws everything as one series when there is no group by', () => {
const result = prepareScatterPlotData({
table: makeTable([value('A'), value('B')], [{ A: 1, B: 2 }]),
dimensions: undefined,
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series).toStrictEqual([
{ label: UNGROUPED_SERIES_LABEL, xs: [1], ys: [2] },
]);
});
it.each([
['a join miss', 'n/a'],
['NaN', 'NaN'],
['Inf', 'Inf'],
['-Inf', '-Inf'],
['an empty cell', undefined],
])('drops a row whose y is %s and counts it', (_, cell) => {
const table = makeTable(
[group('service.name'), value('A'), value('B')],
[
{ 'service.name': 'cart', A: 120, B: 340 },
{ 'service.name': 'checkout', A: 45, B: cell },
],
);
const result = prepareScatterPlotData({
table,
dimensions: undefined,
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual(['cart']);
expect(result.totalGroups).toBe(2);
expect(result.drawnGroups).toBe(1);
});
describe('log axes', () => {
const table = makeTable(
[group('service.name'), value('A'), value('B')],
[
{ 'service.name': 'cart', A: 120, B: 340 },
{ 'service.name': 'idle', A: 0, B: 12 },
{ 'service.name': 'refund', A: 4, B: -1 },
],
);
it('drops values ≤ 0 on a log axis and counts them apart from missing values', () => {
const result = prepareScatterPlotData({
table,
dimensions: undefined,
axes: {
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { scale: DashboardtypesAxisScaleDTO.log },
},
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual(['cart']);
expect(result.nonPositiveOnLogGroups).toBe(2);
expect(result.missingValueGroups).toBe(0);
});
it.each([
DashboardtypesAxisScaleDTO.symlog,
DashboardtypesAxisScaleDTO.auto,
DashboardtypesAxisScaleDTO.linear,
])('keeps values ≤ 0 on a %s axis', (scale) => {
const result = prepareScatterPlotData({
table,
dimensions: undefined,
axes: { x: { scale }, y: { scale } },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.drawnGroups).toBe(3);
expect(result.nonPositiveOnLogGroups).toBe(0);
});
});
it('draws a row with a missing size at the default size', () => {
const table = makeTable(
[group('service.name'), value('A'), value('B'), value('C')],
[
{ 'service.name': 'search', A: 300, B: 120, C: 'n/a' },
{ 'service.name': 'cart', A: 120, B: 340, C: '0.2' },
],
);
const result = prepareScatterPlotData({
table,
dimensions: { size: 'C' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.sizes)).toStrictEqual([
[null],
[0.2],
]);
expect(result.drawnGroups).toBe(2);
});
it('asks for a second value when the query has one aggregation', () => {
const result = prepareScatterPlotData({
table: makeTable(
[group('service.name'), value('A')],
[{ 'service.name': 'cart', A: 1 }],
),
dimensions: undefined,
columnUnits: {},
});
expect(result).toStrictEqual({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 1,
});
});
it('asks for a second value when there is no table yet', () => {
expect(
prepareScatterPlotData({
table: undefined,
dimensions: undefined,
columnUnits: {},
}).status,
).toBe(ScatterPlotDataStatus.NeedsSecondValue);
});
});

View File

@@ -0,0 +1,11 @@
.footer {
flex-shrink: 0;
padding: 4px 8px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,15 @@
import styles from './ScatterPlotFooter.module.scss';
interface ScatterPlotFooterProps {
text: string;
}
function ScatterPlotFooter({ text }: ScatterPlotFooterProps): JSX.Element {
return (
<div className={styles.footer} title={text} data-testid="scatter-plot-footer">
{text}
</div>
);
}
export default ScatterPlotFooter;

View File

@@ -0,0 +1,55 @@
import { ChartScatter } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ScatterPlotPanel'> = {
kind: 'signoz/ScatterPlotPanel',
displayName: 'Scatter Plot',
mode: 'query',
icon: ChartScatter,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {
legend_format: {
state: 'hidden',
},
},
// Same request as Table: one joined row per group, so x and y can come from
// different queries.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,
clone: true,
// Every group of the joined table, plotted or not.
download: { csv: true, png: true, svg: true },
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -0,0 +1,64 @@
import { type ScatterPlotData, ScatterPlotDataStatus } from './types';
export interface ScatterPlotMessage {
title: string;
description: string;
}
function pluralizeGroups(count: number): string {
return count === 1 ? 'group' : 'groups';
}
function logAxisNote(count: number): string {
return `${count} ${pluralizeGroups(count)} with a value ≤ 0 can't go on a log axis`;
}
/**
* Why a query that returned groups still plots nothing. Undefined when the
* query returned no groups (the plain no-data state) or something plots.
*/
export function getScatterPlotEmptyMessage(
data: ScatterPlotData,
): ScatterPlotMessage | undefined {
if (data.totalGroups === 0) {
return undefined;
}
if (data.status === ScatterPlotDataStatus.NeedsSecondValue) {
return {
title: 'Add a second aggregation or query',
description:
'Each dot needs two values, one for X and one for Y. Add an aggregation to this query, or another query grouped by the same labels.',
};
}
if (data.drawnGroups > 0) {
return undefined;
}
const title = `0 of ${data.totalGroups} ${pluralizeGroups(data.totalGroups)} plotted`;
if (data.missingValueGroups === 0) {
return {
title,
description: `${logAxisNote(data.nonPositiveOnLogGroups)}. Switch the axis to symlog to show them.`,
};
}
return {
title,
description:
'No group has both an X and a Y value. Group every query by exactly the same labels; a group missing from one query has no dot.',
};
}
/** "Showing N of M groups", with the log-axis share called out. Undefined when every group plots. */
export function getScatterPlotFooterText(
data: ScatterPlotData,
): string | undefined {
if (
data.status !== ScatterPlotDataStatus.Ready ||
data.drawnGroups === data.totalGroups
) {
return undefined;
}
const showing = `Showing ${data.drawnGroups} of ${data.totalGroups} groups`;
return data.nonPositiveOnLogGroups > 0
? `${showing} · ${logAxisNote(data.nonPositiveOnLogGroups)}`
: showing;
}

View File

@@ -0,0 +1,35 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
DEFAULT_SCATTER_POINT_SIZE,
type ScatterPointSize,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
/** Bounds the spec validates point sizes (px) and opacity against. */
export const POINT_SIZE_BOUNDS = { min: 2, max: 40 } as const;
export const POINT_OPACITY_BOUNDS = { min: 0.1, max: 1 } as const;
export const DEFAULT_POINT_OPACITY = 0.7;
/** What an unset `points` draws as, spelled out for the editor's change tracking. */
export const DEFAULT_POINTS: Required<DashboardtypesScatterPlotPointsDTO> = {
size: DEFAULT_SCATTER_POINT_SIZE.fixed,
minSize: DEFAULT_SCATTER_POINT_SIZE.min,
maxSize: DEFAULT_SCATTER_POINT_SIZE.max,
opacity: DEFAULT_POINT_OPACITY,
};
export function resolvePointSize(
points: DashboardtypesScatterPlotPointsDTO | undefined,
): ScatterPointSize {
return {
fixed: points?.size ?? DEFAULT_SCATTER_POINT_SIZE.fixed,
min: points?.minSize ?? DEFAULT_SCATTER_POINT_SIZE.min,
max: points?.maxSize ?? DEFAULT_SCATTER_POINT_SIZE.max,
};
}
export function resolvePointOpacity(
points: DashboardtypesScatterPlotPointsDTO | undefined,
): number {
return points?.opacity ?? DEFAULT_POINT_OPACITY;
}

View File

@@ -0,0 +1,181 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ScatterSeries } from 'lib/visualization/charts/Scatter/utils';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type {
PanelTableColumn,
PanelTableRow,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getColumnUnit } from '../../utils/getColumnUnit';
import { getValueColumnLabels } from '../../utils/getValueColumnLabels';
import {
type PrepareScatterPlotDataArgs,
type ScatterPlotData,
ScatterPlotDataStatus,
} from './types';
/** Series label when the query has no group by to colour by. */
export const UNGROUPED_SERIES_LABEL = 'All';
/**
* A finite number, or null. The backend sends NaN/±Inf as the strings
* `"NaN"`/`"Inf"`/`"-Inf"` and fills join misses with `"n/a"`; none of those
* may plot, least of all as 0.
*/
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toLabelValue(value: unknown): string {
return typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
? String(value)
: '';
}
function findColumn(
columns: PanelTableColumn[],
key: string | undefined,
): PanelTableColumn | undefined {
return key ? columns.find((column) => column.id === key) : undefined;
}
function isPlaceable(
value: number,
axis: DashboardtypesScatterPlotAxisDTO | undefined,
): boolean {
return axis?.scale !== DashboardtypesAxisScaleDTO.log || value > 0;
}
function getPointLabels(
row: PanelTableRow,
groupColumns: PanelTableColumn[],
): ScatterPointLabel[] {
return groupColumns.map((column) => ({
key: column.name,
value: toLabelValue(row.data[column.id]),
}));
}
/** Joins the values of a dot's colour keys into its series label. */
export const COLOR_LABEL_SEPARATOR = ', ';
/**
* The selected colour keys the result has; every group-by column when none
* is selected or none of the selection is left.
*/
function resolveColorColumns(
groupColumns: PanelTableColumn[],
keys: string[] | null | undefined,
): PanelTableColumn[] {
const selected = groupColumns.filter((column) => keys?.includes(column.id));
return selected.length > 0 ? selected : groupColumns;
}
/**
* One dot per row of the joined scalar table. Unset or stale dimensions fall back
* to the first two value columns and every group-by label.
*/
export function prepareScatterPlotData({
table,
dimensions,
axes,
columnUnits,
}: PrepareScatterPlotDataArgs): ScatterPlotData {
const columns = table?.columns ?? [];
const rows = table?.rows ?? [];
const valueColumns = columns.filter((column) => column.isValueColumn);
const groupColumns = columns.filter((column) => !column.isValueColumn);
const xColumn = findColumn(valueColumns, dimensions?.x) ?? valueColumns[0];
const yColumn =
findColumn(valueColumns, dimensions?.y) ??
valueColumns.find((column) => column !== xColumn);
if (!xColumn || !yColumn) {
return {
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: rows.length,
};
}
const sizeColumn = findColumn(valueColumns, dimensions?.size);
const colorColumns = resolveColorColumns(groupColumns, dimensions?.color);
const seriesByLabel = new Map<
string,
{ series: ScatterSeries; labels: ScatterPointLabel[][] }
>();
let drawnGroups = 0;
let missingValueGroups = 0;
let nonPositiveOnLogGroups = 0;
rows.forEach((row) => {
const x = toFiniteNumber(row.data[xColumn.id]);
const y = toFiniteNumber(row.data[yColumn.id]);
if (x === null || y === null) {
missingValueGroups += 1;
return;
}
if (!isPlaceable(x, axes?.x) || !isPlaceable(y, axes?.y)) {
nonPositiveOnLogGroups += 1;
return;
}
const label =
colorColumns.length > 0
? colorColumns
.map((column) => toLabelValue(row.data[column.id]))
.join(COLOR_LABEL_SEPARATOR)
: UNGROUPED_SERIES_LABEL;
let entry = seriesByLabel.get(label);
if (!entry) {
entry = {
series: { label, xs: [], ys: [], ...(sizeColumn && { sizes: [] }) },
labels: [],
};
seriesByLabel.set(label, entry);
}
entry.series.xs.push(x);
entry.series.ys.push(y);
entry.series.sizes?.push(
sizeColumn ? toFiniteNumber(row.data[sizeColumn.id]) : null,
);
entry.labels.push(getPointLabels(row, groupColumns));
drawnGroups += 1;
});
const entries = [...seriesByLabel.values()];
const labels = getValueColumnLabels(columns);
const toChannel = (
column: PanelTableColumn,
axisLabel?: string,
): { label: string; unit: string | undefined } => ({
label: axisLabel || (labels[column.id] ?? column.name),
unit: getColumnUnit(column.id, columnUnits),
});
return {
status: ScatterPlotDataStatus.Ready,
series: entries.map((entry) => entry.series),
pointLabels: entries.map((entry) => entry.labels),
channels: {
x: toChannel(xColumn, axes?.x?.label),
y: toChannel(yColumn, axes?.y?.label),
...(sizeColumn && { size: toChannel(sizeColumn) }),
},
axisQueries: { x: xColumn.queryName, y: yColumn.queryName },
totalGroups: rows.length,
drawnGroups,
missingValueGroups,
nonPositiveOnLogGroups,
};
}

View File

@@ -0,0 +1,29 @@
import { resolveScatterLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Dimensions },
{ kind: SectionKind.ScatterAxes },
{ kind: SectionKind.ChartAppearance, controls: { points: true } },
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveScatterLegendSeries },
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -0,0 +1,49 @@
import type {
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ScatterSeries } from 'lib/visualization/charts/Scatter/utils';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { PanelTable } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
export interface PrepareScatterPlotDataArgs {
/** The joined scalar table (`formatTableResultForUI`). */
table: PanelTable | undefined;
dimensions: DashboardtypesScatterPlotDimensionsDTO | undefined;
/** A `log` axis drops values ≤ 0, which it cannot place. */
axes?: DashboardtypesScatterPlotAxesDTO;
columnUnits: Record<string, string>;
}
export enum ScatterPlotDataStatus {
Ready = 'ready',
/** Fewer than two value columns: nothing to put on y. */
NeedsSecondValue = 'needsSecondValue',
}
export type ScatterPlotData =
| {
status: ScatterPlotDataStatus.NeedsSecondValue;
totalGroups: number;
}
| {
status: ScatterPlotDataStatus.Ready;
/** One per colour group. */
series: ScatterSeries[];
/** `[seriesIndex][dataIndex]`: the point's group-by labels. */
pointLabels: ScatterPointLabel[][][];
channels: ScatterChannels;
/** The query each axis's column comes from, for drilldown. */
axisQueries: { x: string; y: string };
/** Rows the query returned. */
totalGroups: number;
/** Rows plotted. */
drawnGroups: number;
/** Rows missing a finite x or y, e.g. a group absent from one query. */
missingValueGroups: number;
/** Rows with a value ≤ 0 on a `log` axis. */
nonPositiveOnLogGroups: number;
};

View File

@@ -0,0 +1,28 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
type ScatterAxisOptions,
ScatterAxisScale,
} from 'lib/visualization/charts/Scatter/utils';
const CHART_SCALE: Record<DashboardtypesAxisScaleDTO, ScatterAxisScale> = {
[DashboardtypesAxisScaleDTO.auto]: ScatterAxisScale.Auto,
[DashboardtypesAxisScaleDTO.linear]: ScatterAxisScale.Linear,
[DashboardtypesAxisScaleDTO.log]: ScatterAxisScale.Log,
[DashboardtypesAxisScaleDTO.symlog]: ScatterAxisScale.SymLog,
};
export function toScatterAxisOptions(
axis: DashboardtypesScatterPlotAxisDTO | undefined,
unit: string | undefined,
): ScatterAxisOptions {
return {
label: axis?.label,
unit,
softMin: axis?.softMin,
softMax: axis?.softMax,
scale: axis?.scale ? CHART_SCALE[axis.scale] : ScatterAxisScale.Auto,
};
}

View File

@@ -79,6 +79,29 @@ describe('buildTableCsvRows', () => {
});
});
describe('buildTableCsvRows headers', () => {
it('keeps both of two columns that share a name, under their query labels', () => {
const rows = buildTableCsvRows({
table: {
queryName: 'A',
legend: '',
columns: [
{ name: 'service', queryName: '', isValueColumn: false, id: 'service' },
{ name: 'count()', queryName: 'A', isValueColumn: true, id: 'A.count()' },
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A.p99' },
{ name: 'count()', queryName: 'B', isValueColumn: true, id: 'B' },
],
rows: [{ data: { service: 'cart', 'A.count()': 120, 'A.p99': 4, B: 3 } }],
},
columnUnits: {},
});
expect(rows).toStrictEqual([
{ service: 'cart', 'A.count()': '120', p99: '4', 'B.count()': '3' },
]);
});
});
describe('getTableCsvRows', () => {
const panel = {
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
@@ -96,6 +119,23 @@ describe('getTableCsvRows', () => {
]);
});
it('exports a scatter plot from the same joined table', () => {
mockPrepareScalarTables.mockReturnValue([table]);
const scatter = {
spec: {
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { formatting: { columnUnits: { A: 'ns' } } },
},
},
} as unknown as PanelOfKind<'signoz/ScatterPlotPanel'>;
expect(getTableCsvRows(scatter, data)).toStrictEqual([
{ service: 'frontend', p99: '1234ns' },
{ service: 'cart', p99: '56ns' },
]);
});
it('returns no rows when the response has no table', () => {
mockPrepareScalarTables.mockReturnValue([]);

View File

@@ -25,7 +25,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
// Asks the server to transpose its scalar result into UI rows (as does Scatter Plot).
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,

View File

@@ -8,6 +8,7 @@ import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { getColumnUnit } from '../../utils/getColumnUnit';
import { getValueColumnLabels } from '../../utils/getValueColumnLabels';
import type { PanelOfKind } from '../../types/rendererProps';
import { formatTableCellText } from './tableColumns';
@@ -19,6 +20,26 @@ interface BuildTableCsvRowsArgs {
decimalPrecision?: PrecisionOption;
}
/**
* Column key → CSV header. Rows are keyed by header, so two columns sharing a
* name (`count()` on two queries) would collapse into one; those take their
* query-prefixed label instead.
*/
function getCsvHeaders(table: PanelTable): Record<string, string> {
const nameCounts = new Map<string, number>();
table.columns.forEach((col) =>
nameCounts.set(col.name, (nameCounts.get(col.name) ?? 0) + 1),
);
const labels = getValueColumnLabels(table.columns);
return Object.fromEntries(
table.columns.map((col) => {
const key = col.id || col.name;
const collides = (nameCounts.get(col.name) ?? 0) > 1;
return [key, collides ? (labels[key] ?? col.name) : col.name];
}),
);
}
/**
* Flattens a prepared table into CSV rows keyed by column name, reusing the
* on-screen cell formatting in display column order. Exports the full result
@@ -29,12 +50,13 @@ export function buildTableCsvRows({
columnUnits,
decimalPrecision,
}: BuildTableCsvRowsArgs): Record<string, string>[] {
const headers = getCsvHeaders(table);
return table.rows.map((row) => {
const csvRow: Record<string, string> = {};
table.columns.forEach((col) => {
const key = col.id || col.name;
const unit = getColumnUnit(key, columnUnits);
csvRow[col.name] = formatTableCellText(
csvRow[headers[key]] = formatTableCellText(
col,
row.data[key],
unit,
@@ -50,7 +72,7 @@ export function buildTableCsvRows({
* reusing the on-screen formatting. Returns [] when the response has no table.
*/
export function getTableCsvRows(
panel: PanelOfKind<'signoz/TablePanel'>,
panel: PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
data: PanelQueryData,
): Record<string, string>[] {
const spec = panel.spec.plugin.spec;

View File

@@ -3,6 +3,7 @@ import { definition as BarChart } from './kinds/BarChartPanel/definition';
import { definition as Histogram } from './kinds/HistogramPanel/definition';
import { definition as NumberValue } from './kinds/NumberPanel/definition';
import { definition as PieChart } from './kinds/PieChartPanel/definition';
import { definition as ScatterPlot } from './kinds/ScatterPlotPanel/definition';
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
@@ -24,6 +25,7 @@ export const PANELS: PanelRegistry = {
[BarChart.kind]: BarChart,
[AreaChart.kind]: AreaChart,
[PieChart.kind]: PieChart,
[ScatterPlot.kind]: ScatterPlot,
[Histogram.kind]: Histogram,
[List.kind]: List,
[Text.kind]: Text,

View File

@@ -36,6 +36,9 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/ScatterPlotPanel': {
onClick?: (event: DrilldownClickPayload) => void;
};
};
/**

View File

@@ -25,6 +25,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
'signoz/TextPanel': PANEL_TYPES.TEXT,
'signoz/ScatterPlotPanel': PANEL_TYPES.SCATTER,
};
/**

View File

@@ -11,6 +11,9 @@ import type {
DashboardtypesLineStyleDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
DashboardtypesScatterPlotPointsDTO,
DashboardtypesSpanGapsDTO,
DashboardtypesStackModeDTO,
DashboardtypesTableFormattingDTO,
@@ -25,6 +28,7 @@ import {
Antenna,
BarChart,
Columns3,
Crosshair,
Hash,
Link2,
Palette,
@@ -62,6 +66,9 @@ export enum SectionKind {
Columns = 'columns',
TextLayout = 'presentation',
PanelHeader = 'headerOptions',
Dimensions = 'dimensions',
/** Scatter Plot's per-axis `axes` shape; `Axes` is the single y-axis shape. */
ScatterAxes = 'scatterAxes',
}
/**
@@ -101,6 +108,8 @@ export interface PanelChartAppearanceSlice {
fillOpacity?: DashboardtypesFillOpacityDTO;
showPoints?: boolean;
spanGaps?: DashboardtypesSpanGapsDTO;
/** Scatter Plot's dot size and opacity. */
points?: DashboardtypesScatterPlotPointsDTO;
}
/** Superset spanning every kind's visualization DTO. */
@@ -127,6 +136,8 @@ export interface SectionSpecMap {
[SectionKind.Columns]: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
[SectionKind.TextLayout]: DashboardtypesTextPresentationDTO; // spec.plugin.spec.presentation (Text)
[SectionKind.PanelHeader]: DashboardtypesHeaderOptionsDTO; // spec.plugin.spec.headerOptions (Text)
[SectionKind.Dimensions]: DashboardtypesScatterPlotDimensionsDTO; // spec.plugin.spec.dimensions (Scatter Plot)
[SectionKind.ScatterAxes]: DashboardtypesScatterPlotAxesDTO; // spec.plugin.spec.axes (Scatter Plot)
}
/**
@@ -157,6 +168,8 @@ export interface SectionControls {
fillOpacity?: boolean;
showPoints?: boolean;
spanGaps?: boolean;
/** Scatter Plot's dots: one size, or a min/max range when a size column is bound. */
points?: boolean;
};
[SectionKind.Buckets]: {
count?: boolean;
@@ -184,7 +197,9 @@ export type AtomicSectionKind =
| SectionKind.ContextLinks
| SectionKind.Columns
| SectionKind.TextLayout
| SectionKind.PanelHeader;
| SectionKind.PanelHeader
| SectionKind.Dimensions
| SectionKind.ScatterAxes;
/** Predicate to hide a section from the current spec; returning true removes it. */
export type SectionVisibilityPredicate = (
@@ -219,6 +234,8 @@ export const SECTION_METADATA = {
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
[SectionKind.TextLayout]: { title: 'Panel appearance', icon: AlignLeft },
[SectionKind.PanelHeader]: { title: 'Panel header', icon: PanelTop },
[SectionKind.Dimensions]: { title: 'Dimensions', icon: Crosshair },
[SectionKind.ScatterAxes]: { title: 'Axes', icon: Scale3D },
} as const satisfies Record<SectionKind, SectionMetadata>;
/**

View File

@@ -535,6 +535,40 @@ describe('buildPluginSpec', () => {
});
});
it('carries per-column units between kinds that both key them by column (Table ↔ Scatter Plot)', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
];
const oldSpec = oldSpecWith(
{
formatting: {
columnUnits: { 'A.count()': 'short', 'A.p99(duration_nano)': 'ns' },
decimalPrecision: 1,
},
},
[builderQueryNamed('A')],
);
expect(buildPluginSpec(sections, { oldSpec }).formatting).toStrictEqual({
decimalPrecision: 1,
columnUnits: { 'A.count()': 'short', 'A.p99(duration_nano)': 'ns' },
});
});
it('keeps an emptied column unit map empty rather than fanning a unit out', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { columnUnits: true } },
];
const oldSpec = oldSpecWith({ formatting: { columnUnits: {} } }, [
builderQueryNamed('A'),
]);
expect(buildPluginSpec(sections, { oldSpec }).formatting).toBeUndefined();
});
it('never seeds a panel-wide unit from per-column units (Table → TimeSeries)', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },

View File

@@ -0,0 +1,39 @@
import type { PanelTableColumn } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getValueColumnLabels } from '../getValueColumnLabels';
const column = (
id: string,
name: string,
queryName: string,
isValueColumn = true,
): PanelTableColumn => ({ id, name, queryName, isValueColumn });
describe('getValueColumnLabels', () => {
it('prefixes every value column with its query', () => {
expect(
getValueColumnLabels([
column('service.name', 'service.name', '', false),
column('A.count()', 'count()', 'A'),
column('A.p99(duration_nano)', 'p99(duration_nano)', 'A'),
column('B', 'count()', 'B'),
]),
).toStrictEqual({
'A.count()': 'A.count()',
'A.p99(duration_nano)': 'A.p99(duration_nano)',
B: 'B.count()',
});
});
it('leaves a name that already is the query name bare', () => {
expect(
getValueColumnLabels([column('F1', 'F1', 'F1'), column('A', 'A', 'A')]),
).toStrictEqual({ F1: 'F1', A: 'A' });
});
it('leaves a column with no query name bare', () => {
expect(getValueColumnLabels([column('x', 'x', '')])).toStrictEqual({
x: 'x',
});
});
});

View File

@@ -288,6 +288,13 @@ const SECTION_SEEDS: SectionSeeds = {
decimalPrecision: old.decimalPrecision,
}),
};
// Kinds that both key units per column (Table ↔ Scatter Plot) share the keys.
if (controls.columnUnits && old?.columnUnits) {
if (Object.keys(old.columnUnits).length > 0) {
carried.columnUnits = { ...old.columnUnits };
}
return carried;
}
// A panel-wide unit fans out to every value column when the target keys units
// per column (→ Table). One-way: `columnUnits` never seed a panel-wide `unit`.
const unit = old?.unit;

View File

@@ -0,0 +1,76 @@
import type { BuilderQuery } from 'types/api/v5/queryRange';
import { enrichScatterClick } from '../enrichScatterClick';
const builderQuery = (spec: Record<string, unknown>): BuilderQuery =>
spec as unknown as BuilderQuery;
const QUERIES = [
builderQuery({
name: 'A',
signal: 'traces',
groupBy: [{ name: 'service.name' }, { name: 'k8s.namespace.name' }],
}),
builderQuery({
name: 'B',
signal: 'logs',
groupBy: [{ name: 'service.name' }],
}),
];
const LABELS = [
{ key: 'service.name', value: 'checkout' },
{ key: 'k8s.namespace.name', value: 'prod' },
];
const click = (
axisQueries: { x: string; y: string },
builderQueries = QUERIES,
): ReturnType<typeof enrichScatterClick> =>
enrichScatterClick({
labels: LABELS,
label: 'checkout, prod',
color: '#4E74F8',
axisQueries,
builderQueries,
coordinates: { x: 7, y: 8 },
timeRange: { startTime: 1, endTime: 2 },
});
describe('enrichScatterClick', () => {
it("follows Y's query and filters to the dot's group", () => {
expect(click({ x: 'B', y: 'A' })).toStrictEqual({
coordinates: { x: 7, y: 8 },
context: {
queryName: 'A',
signal: 'traces',
filters: [
{ filterKey: 'service.name', filterValue: 'checkout', operator: '=' },
{ filterKey: 'k8s.namespace.name', filterValue: 'prod', operator: '=' },
],
timeRange: { startTime: 1, endTime: 2 },
label: 'checkout, prod',
seriesColor: '#4E74F8',
},
});
});
it('keeps only the labels the followed query groups by', () => {
expect(click({ x: 'A', y: 'B' })?.context).toMatchObject({
queryName: 'B',
signal: 'logs',
filters: [
{ filterKey: 'service.name', filterValue: 'checkout', operator: '=' },
],
});
});
it("falls back to X's query when Y's is not a builder query", () => {
expect(click({ x: 'A', y: 'F1' })?.context.queryName).toBe('A');
});
it('returns null when neither axis has a builder query', () => {
expect(click({ x: 'F1', y: 'F2' })).toBeNull();
expect(click({ x: 'A', y: 'A' }, [])).toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichScatterClickArgs {
/** The dot's group-by labels. */
labels: ScatterPointLabel[];
/** The dot's series label, as the legend names it. */
label: string;
color: string;
/** Y's query first: a dot is placed by both, but Y is usually what was measured. */
axisQueries: { x: string; y: string };
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** The panel's fetched window; a dot summarises all of it. */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a dot click into a drilldown payload filtered to the dot's group. The
* drilldown follows Y's query, or X's when Y has no builder query (a formula,
* PromQL). Returns `null` when neither does.
*/
export function enrichScatterClick({
labels,
label,
color,
axisQueries,
builderQueries,
coordinates,
timeRange,
}: EnrichScatterClickArgs): DrilldownClickPayload | null {
const queryName = [axisQueries.y, axisQueries.x].find(
(name) =>
isValidQueryName(name) &&
builderQueries.some((query) => query.name === name),
);
const builderQuery = builderQueries.find((query) => query.name === queryName);
if (!queryName || !builderQuery) {
return null;
}
const groupLabels = Object.fromEntries(
labels.map((point) => [point.key, point.value]),
);
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: getGroupByFilters(groupLabels, builderQuery),
timeRange,
label,
seriesColor: color,
},
};
}

View File

@@ -0,0 +1,20 @@
import type { PanelTableColumn } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
/**
* Value column id → `queryName.name`, so `count()` on two queries reads apart.
* A name that already is the query name (a formula, a PromQL query) stays bare.
*/
export function getValueColumnLabels(
columns: PanelTableColumn[],
): Record<string, string> {
return Object.fromEntries(
columns
.filter((column) => column.isValueColumn)
.map((column) => [
column.id || column.name,
column.queryName && column.name !== column.queryName
? `${column.queryName}.${column.name}`
: column.name,
]),
);
}

View File

@@ -1,8 +1,13 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
import { prepareScatterPlotData } from '../kinds/ScatterPlotPanel/prepareData';
import { ScatterPlotDataStatus } from '../kinds/ScatterPlotPanel/types';
import { getBuilderQueries } from './getBuilderQueries';
import { resolveSeriesLabelV5 } from './resolveSeriesLabel';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
@@ -23,6 +28,8 @@ export interface LegendSeries {
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
interface LegendSeriesArgs {
/** The kind's own spec, for kinds whose labels depend on it. */
spec: DashboardtypesPanelSpecDTO;
queries: PanelQueries;
data: PanelQueryData;
isDarkMode: boolean;
@@ -103,3 +110,37 @@ export function resolveTimeSeriesLegendSeries({
(label) => generateColor(label, palette),
);
}
/**
* Scatter Plot: one entry per colour group, which `dimensions.color` picks, so the
* labels come from the same data prep the renderer runs.
*/
export function resolveScatterLegendSeries({
spec,
data,
isDarkMode,
}: LegendSeriesArgs): LegendSeries[] {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return [];
}
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;
const scatterData = prepareScatterPlotData({
table: prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0),
dimensions: spec.plugin.spec.dimensions,
axes: spec.plugin.spec.axes,
columnUnits: {},
});
if (scatterData.status !== ScatterPlotDataStatus.Ready) {
return [];
}
return buildLegendSeries(
scatterData.series.map((series) => series.label),
(label) => generateColor(label, palette),
);
}

View File

@@ -40,6 +40,11 @@ const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
description: 'Compare values across categories',
},
'signoz/PieChartPanel': { group: 'compare', description: 'Share of a whole' },
'signoz/ScatterPlotPanel': {
group: 'compare',
description: 'Two values per group, plotted against each other',
isNew: true,
},
'signoz/HistogramPanel': {
group: 'distributions',
description: 'Distribution of values into buckets',

View File

@@ -10,6 +10,19 @@ const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
const RING_TRACK = { stroke: 'var(--l3-background)' };
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
const DOT_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.55 };
/** `[cx, cy, r]`: a loose up-and-right cloud with one large outlier. */
const SCATTER_DOTS = [
[14, 34, 2.5],
[26, 28, 3.5],
[38, 31, 2],
[50, 20, 4],
[62, 24, 2.5],
[76, 14, 3],
[88, 18, 2],
[102, 8, 6],
];
const LINE_PATH =
'M0 34 12 26 24 30 36 16 48 22 60 10 72 18 84 8 96 14 108 5 120 11';
@@ -73,6 +86,13 @@ export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
className={styles.histogram}
/>
),
'signoz/ScatterPlotPanel': (
<svg viewBox="0 0 120 44" className={styles.svg}>
{SCATTER_DOTS.map(([cx, cy, r]) => (
<circle key={cx} cx={cx} cy={cy} r={r} style={DOT_FILL} />
))}
</svg>
),
'signoz/PieChartPanel': (
<svg viewBox="0 0 44 44" className={styles.svg}>
<circle

View File

@@ -0,0 +1,45 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useDrilldownBreakout } from '../useDrilldownBreakout';
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('container/QueryTable/Drilldown/tableDrilldownUtils', () => ({
...jest.requireActual('container/QueryTable/Drilldown/tableDrilldownUtils'),
getBreakoutQuery: jest.fn((query: Query) => query),
}));
const QUERY = {
queryType: 'builder',
builder: { queryData: [{ queryName: 'A' }], queryFormulas: [] },
} as unknown as Query;
const AGGREGATE = { queryName: 'A', filters: [] } as unknown as AggregateData;
function breakOutFrom(panelType: PANEL_TYPES): PANEL_TYPES {
const openViewWithQuery = jest.fn();
const { result } = renderHook(() =>
useDrilldownBreakout({
panelId: 'p1',
v1Query: QUERY,
panelType,
aggregateData: AGGREGATE,
openViewWithQuery,
onClose: jest.fn(),
}),
);
result.current.onBreakout({ name: 'host.name' } as never);
return openViewWithQuery.mock.calls[0][2];
}
describe('useDrilldownBreakout', () => {
it('opens a scatter plot breakout as a table', () => {
expect(breakOutFrom(PANEL_TYPES.SCATTER)).toBe(PANEL_TYPES.TABLE);
});
it('keeps the panel type for the kinds V1 breaks out in place', () => {
expect(breakOutFrom(PANEL_TYPES.TIME_SERIES)).toBe(PANEL_TYPES.TIME_SERIES);
expect(breakOutFrom(PANEL_TYPES.VALUE)).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -13,9 +13,10 @@ interface UseDownloadPanelCsvArgs {
data: PanelQueryData;
/**
* Whether the kind's definition declares CSV as a downloadable format
* (`actions.download.csv`). Only tables carry tabular data, so this is the
* same gate the menu uses — kept here so the callback stays a no-op when
* invoked for a kind that can't produce CSV.
* (`actions.download.csv`). Only kinds drawn from a joined scalar table
* (Table, Scatter Plot) carry tabular data, so this is the same gate the menu
* uses — kept here so the callback stays a no-op when invoked for a kind that
* can't produce CSV.
*/
canDownloadCsv: boolean;
}
@@ -35,7 +36,10 @@ export function useDownloadPanelCsv({
if (!canDownloadCsv) {
return;
}
const rows = getTableCsvRows(panel as PanelOfKind<'signoz/TablePanel'>, data);
const rows = getTableCsvRows(
panel as PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
data,
);
if (rows.length === 0) {
return;
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
getBreakoutPanelType,
@@ -66,7 +66,13 @@ export function useDrilldownBreakout({
groupBy,
aggregateData.filters ?? [],
);
openViewWithQuery(panelId, breakoutQuery, getBreakoutPanelType(panelType));
// A breakout regroups one query, which would stop a scatter plot's queries
// joining on the same labels; a table shows the regrouped query on its own.
const breakoutPanelType =
panelType === PANEL_TYPES.SCATTER
? PANEL_TYPES.TABLE
: getBreakoutPanelType(panelType);
openViewWithQuery(panelId, breakoutQuery, breakoutPanelType);
onClose();
},
[aggregateData, v1Query, panelType, panelId, openViewWithQuery, onClose],

View File

@@ -5,7 +5,7 @@ import type {
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
envelopesToQuery,
@@ -123,6 +123,35 @@ describe('persesQueryAdapters', () => {
expect(result[0].kind).toBe('scalar');
});
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER])(
'sends a %s panel metric query as scalar with its reduceTo, which the API requires',
(panelType) => {
const metrics = initialQueriesMap[DataSource.METRICS];
const query: Query = {
...metrics,
builder: {
...metrics.builder,
queryData: metrics.builder.queryData.map((queryData) => ({
...queryData,
// `aggregations` is a union of arrays, which `map` widens.
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
reduceTo: ReduceOperators.SUM,
})) as typeof queryData.aggregations,
})),
},
};
const [envelope] = toPerses(query, panelType);
const composite = envelope.spec.plugin.spec as {
queries: { spec: { aggregations?: { reduceTo?: string }[] } }[];
};
expect(envelope.kind).toBe('scalar');
expect(composite.queries[0].spec.aggregations?.[0]?.reduceTo).toBe('sum');
},
);
it('emits a bare signoz/BuilderQuery for a List panel (not a CompositeQuery)', () => {
const result = toPerses(
initialQueriesMap[DataSource.LOGS],

View File

@@ -106,6 +106,7 @@ export function panelTypeToRequestType(
case PANEL_TYPES.TABLE:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
case PANEL_TYPES.SCATTER:
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;

View File

@@ -17,6 +17,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
// Dashboard-only visualisation; explorers never offer it.
[PANEL_TYPES.TEXT]: ExplorerViews.LIST,
[PANEL_TYPES.SCATTER]: ExplorerViews.TABLE,
[PANEL_TYPES.EMPTY_WIDGET]: ExplorerViews.LIST,
};