mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-02 00:00:42 +01:00
Compare commits
8 Commits
feat/scatt
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b9c1bb2b | ||
|
|
c50a90ac76 | ||
|
|
db03efb495 | ||
|
|
4c36a55188 | ||
|
|
3bad49e8ed | ||
|
|
ab4cae6fa1 | ||
|
|
8b87bd7c3a | ||
|
|
a0d9e9c8fd |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
SCATTER = 'scatter',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}`,
|
||||
]);
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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)}`,
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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']));
|
||||
|
||||
|
||||
@@ -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: [] }]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.chart {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user