mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-22 19:30:43 +01:00
Compare commits
25 Commits
feat/heatm
...
fix/heatma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e72b40b144 | ||
|
|
cd4791bc67 | ||
|
|
bdf991964b | ||
|
|
dc0da3eee7 | ||
|
|
41c0a3754c | ||
|
|
4c3469a5ca | ||
|
|
23cf713be4 | ||
|
|
9be4db51bd | ||
|
|
bb9c3fc095 | ||
|
|
a093eb041a | ||
|
|
308b05ec3b | ||
|
|
64193f0376 | ||
|
|
f0e7663d85 | ||
|
|
8a4965d1fb | ||
|
|
1041801cc1 | ||
|
|
0ec5756645 | ||
|
|
939a244a18 | ||
|
|
688dba6e13 | ||
|
|
b051e14643 | ||
|
|
b72456f935 | ||
|
|
a10be0d95d | ||
|
|
73373f654f | ||
|
|
30bc4411d5 | ||
|
|
90a55ce72d | ||
|
|
67afca0016 |
@@ -5072,6 +5072,83 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
export enum DashboardtypesHeatmapYScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
}
|
||||
export interface DashboardtypesHeatmapAxesDTO {
|
||||
yScale?: DashboardtypesHeatmapYScaleDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesHeatmapColorModeDTO {
|
||||
palette = 'palette',
|
||||
opacity = 'opacity',
|
||||
}
|
||||
export enum DashboardtypesHeatmapPaletteDTO {
|
||||
ice = 'ice',
|
||||
moss = 'moss',
|
||||
rust = 'rust',
|
||||
graphite = 'graphite',
|
||||
ember = 'ember',
|
||||
lagoon = 'lagoon',
|
||||
orchid = 'orchid',
|
||||
verdant = 'verdant',
|
||||
lava = 'lava',
|
||||
beacon = 'beacon',
|
||||
}
|
||||
export enum DashboardtypesHeatmapColorScaleDTO {
|
||||
log = 'log',
|
||||
sqrt = 'sqrt',
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface DashboardtypesHeatmapColorsDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fill?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
maxCount?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
minCount?: number | null;
|
||||
mode?: DashboardtypesHeatmapColorModeDTO;
|
||||
palette?: DashboardtypesHeatmapPaletteDTO;
|
||||
scale?: DashboardtypesHeatmapColorScaleDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapChartAppearanceDTO {
|
||||
colors?: DashboardtypesHeatmapColorsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapPanelSpecDTO {
|
||||
axes?: DashboardtypesHeatmapAxesDTO;
|
||||
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/HeatmapPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
|
||||
spec: DashboardtypesHeatmapPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5080,7 +5157,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6005,6 +6083,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
|
||||
@@ -198,6 +198,10 @@ function createBaseSpec(
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
having: normalizeHaving(queryData.having),
|
||||
// Heatmap only. Every other request type rejects an axis, and
|
||||
// `panelTypeDataSourceFormValuesMap` is what keeps one from being carried onto a
|
||||
// query the panel type switched away from.
|
||||
bucketOptions: queryData.bucketOptions,
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map((func: QueryFunction): QueryFunction => {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
|
||||
.label {
|
||||
// Typography.Text takes its display from this token; at its `inline` default
|
||||
// the label blockifies as a flex item and the text rides the top of the row.
|
||||
--typography-text-display: flex;
|
||||
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Borrows the query builder's control metrics rather than the component defaults the
|
||||
* toggle group and number input ship with: 36px tall, 2px radius, and the
|
||||
* `--query-builder-v2-*` surface the selects above this row already paint on.
|
||||
*/
|
||||
.bucketOptions {
|
||||
--toggle-group-radius: var(--radius-1);
|
||||
--toggle-group-item-size: 36px;
|
||||
--toggle-group-item-font-size: 13px;
|
||||
--toggle-group-item-padding-left: var(--spacing-6);
|
||||
--toggle-group-item-padding-right: var(--spacing-6);
|
||||
|
||||
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
|
||||
// formula, which has no add-ons ancestor to pick the toggle palette up from.
|
||||
--toggle-group-secondary-bg: var(
|
||||
--query-builder-v2-toggle-group-background-color,
|
||||
var(--l1-background-hover)
|
||||
);
|
||||
--toggle-group-secondary-border: var(
|
||||
--query-builder-v2-toggle-group-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--toggle-group-secondary-active-bg: var(
|
||||
--query-builder-v2-toggle-group-active-background-color,
|
||||
var(--l1-background)
|
||||
);
|
||||
--toggle-group-secondary-bg-hover: var(
|
||||
--query-builder-v2-toggle-group-background-color-hover,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
--input-height: 36px;
|
||||
--input-font-size: 13px;
|
||||
--input-border-radius: var(--radius-1);
|
||||
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
--input-background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
padding: var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
color: var(--l3-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle group exposes a font-size token but no family, so each item's label carries
|
||||
* the query builder's value type itself.
|
||||
*/
|
||||
.toggleLabel {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
|
||||
.numberInput {
|
||||
width: 104px;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Unfilled: a pill on the same row as the toggles above reads as another control,
|
||||
* and the bounds are derived rather than set here. */
|
||||
.bound {
|
||||
padding: var(--spacing-1) 0;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
.overflowBound {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintIcon {
|
||||
flex: none;
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
margin-left: auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ChevronUp, Info } from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
BUCKET_KIND_HINTS,
|
||||
BUCKET_KIND_OPTIONS,
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
LOG_SCALE_OPTIONS,
|
||||
MAX_NUM_BUCKETS,
|
||||
} from './constants';
|
||||
import {
|
||||
BucketKindOption,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
isLinearBuckets,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
logScaleOf,
|
||||
previewUpperBounds,
|
||||
} from './utils';
|
||||
|
||||
import styles from './BucketOptions.module.scss';
|
||||
|
||||
function BucketOptions({
|
||||
bucketOptions,
|
||||
unit,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
|
||||
unit?: string;
|
||||
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
|
||||
/** Omitted where the section isn't dismissable, as on a formula. */
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
// A linear axis has no bounds to describe until it has a max value, so the picked
|
||||
// kind is held here rather than read back off the emitted options: it has to survive
|
||||
// the gap between choosing Linear and filling the field in.
|
||||
const [kind, setKind] = useState<BucketKindOption>(
|
||||
kindOptionOf(bucketOptions),
|
||||
);
|
||||
const linearSpec =
|
||||
bucketOptions && isLinearBuckets(bucketOptions)
|
||||
? bucketOptions.spec
|
||||
: undefined;
|
||||
const [logScale, setLogScale] = useState<number>(logScaleOf(bucketOptions));
|
||||
const [maxValue, setMaxValue] = useState<number | null>(
|
||||
linearSpec?.maxValue ?? null,
|
||||
);
|
||||
const [numBuckets, setNumBuckets] = useState<number | null>(
|
||||
linearSpec?.numBuckets ?? null,
|
||||
);
|
||||
|
||||
const emitLinear = useCallback(
|
||||
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
|
||||
// An incomplete linear axis is sent as no axis at all rather than as a spec the
|
||||
// request would reject.
|
||||
if (nextMaxValue === null || nextMaxValue <= 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKindChange = useCallback(
|
||||
(value: string): void => {
|
||||
// Radix clears the value when the active item is clicked again; a bucket axis is
|
||||
// always one of the three, so keep the current pick instead.
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKind = value as BucketKindOption;
|
||||
setKind(nextKind);
|
||||
|
||||
if (nextKind === 'auto') {
|
||||
onChange(undefined);
|
||||
} else if (nextKind === 'log') {
|
||||
onChange(logBuckets(logScale));
|
||||
} else {
|
||||
emitLinear(maxValue, numBuckets);
|
||||
}
|
||||
},
|
||||
[emitLinear, logScale, maxValue, numBuckets, onChange],
|
||||
);
|
||||
|
||||
const handleScaleChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScale = Number(value);
|
||||
setLogScale(nextScale);
|
||||
onChange(logBuckets(nextScale));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleMaxValueChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setMaxValue(next);
|
||||
emitLinear(next, numBuckets);
|
||||
},
|
||||
[emitLinear, numBuckets],
|
||||
);
|
||||
|
||||
const handleNumBucketsChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setNumBuckets(next);
|
||||
emitLinear(maxValue, next);
|
||||
},
|
||||
[emitLinear, maxValue],
|
||||
);
|
||||
|
||||
// The toggle group takes a ReactNode label, which is how each item picks up the
|
||||
// query builder's value type — the component exposes no font-family token.
|
||||
const kindItems = useMemo(
|
||||
() =>
|
||||
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const scaleItems = useMemo(
|
||||
() =>
|
||||
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
// The kind toggle can be ahead of what has been emitted, so the preview describes
|
||||
// the picked kind rather than the emitted options.
|
||||
const previewedOptions = useMemo(():
|
||||
| Querybuildertypesv5BucketOptionsDTO
|
||||
| undefined => {
|
||||
if (kind === 'log') {
|
||||
return logBuckets(logScale);
|
||||
}
|
||||
if (kind === 'linear' && maxValue !== null) {
|
||||
return linearBuckets(maxValue, numBuckets);
|
||||
}
|
||||
return undefined;
|
||||
}, [kind, logScale, maxValue, numBuckets]);
|
||||
|
||||
const bounds =
|
||||
kind === 'linear' && !previewedOptions
|
||||
? undefined
|
||||
: previewUpperBounds(previewedOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.bucketOptions} data-testid="bucket-options">
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Bucket by</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={kind}
|
||||
items={kindItems}
|
||||
onChange={handleKindChange}
|
||||
testId="bucket-options-kind"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kind === 'log' && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Scale</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={String(logScale)}
|
||||
items={scaleItems}
|
||||
onChange={handleScaleChange}
|
||||
testId="bucket-options-scale"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'linear' && (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Max value</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={0}
|
||||
value={maxValue}
|
||||
onChange={handleMaxValueChange}
|
||||
placeholder="Required"
|
||||
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
|
||||
data-testid="bucket-options-max-value"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Buckets</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={1}
|
||||
max={MAX_NUM_BUCKETS}
|
||||
precision={0}
|
||||
value={numBuckets}
|
||||
onChange={handleNumBucketsChange}
|
||||
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
|
||||
data-testid="bucket-options-num-buckets"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
className={cx('periscope-btn', 'ghost', styles.closeBtn)}
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={onClose}
|
||||
data-testid="bucket-options-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.bounds} data-testid="bucket-options-bounds">
|
||||
<span className={styles.label}>
|
||||
Bounds
|
||||
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
|
||||
<Info size={12} className={styles.hintIcon} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
{bounds ? (
|
||||
<>
|
||||
{bounds.map((bound) => (
|
||||
<span className={styles.bound} key={bound}>
|
||||
{formatUpperBound(bound, unit)}
|
||||
</span>
|
||||
))}
|
||||
{hasBoundsBeyondPreview(previewedOptions) && (
|
||||
<span className={styles.muted}>…</span>
|
||||
)}
|
||||
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.muted}>Set a max value to see the bounds</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BucketOptions.defaultProps = {
|
||||
bucketOptions: undefined,
|
||||
unit: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default BucketOptions;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MAX_LOG_SCALE } from '../constants';
|
||||
import {
|
||||
bandsPerDoublingFromScale,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
previewUpperBounds,
|
||||
} from '../utils';
|
||||
|
||||
describe('bucket option scales', () => {
|
||||
it.each([
|
||||
[-2, 0.25],
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 4],
|
||||
[3, 8],
|
||||
[4, 16],
|
||||
])('scale %i is %i bands per doubling', (scale, bands) => {
|
||||
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kindOptionOf', () => {
|
||||
it('reads no options as auto', () => {
|
||||
expect(kindOptionOf(undefined)).toBe('auto');
|
||||
});
|
||||
|
||||
it('reads the kind off the options', () => {
|
||||
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
|
||||
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewUpperBounds', () => {
|
||||
it('doubles at one band per doubling', () => {
|
||||
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
|
||||
1, 2, 4, 8, 16, 32, 64, 128,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the finest log axis when no options are set', () => {
|
||||
const bounds = previewUpperBounds(undefined);
|
||||
|
||||
expect(bounds).toHaveLength(8);
|
||||
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
|
||||
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
|
||||
});
|
||||
|
||||
it('spaces a linear axis evenly by bucket width', () => {
|
||||
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
|
||||
);
|
||||
});
|
||||
|
||||
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
|
||||
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
|
||||
});
|
||||
|
||||
it('has no bounds to preview for a linear axis without a usable max value', () => {
|
||||
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
|
||||
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBoundsBeyondPreview', () => {
|
||||
it('is always true for a log axis, which has no top', () => {
|
||||
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
|
||||
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks whether a linear axis runs past the preview', () => {
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpperBound', () => {
|
||||
it('reads a bound in the panel unit when one is set', () => {
|
||||
expect(formatUpperBound(1, 'ms')).toContain('ms');
|
||||
});
|
||||
|
||||
it('keeps three significant digits when unitless', () => {
|
||||
expect(formatUpperBound(128, undefined)).toBe('128');
|
||||
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
|
||||
* builder keeps its own copy so an out-of-range axis is refused before the request
|
||||
* rather than after it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
|
||||
* resolution ClickHouse buckets at, so it is both the finest available and what an
|
||||
* absent `bucketOptions` resolves to.
|
||||
*/
|
||||
export const MAX_LOG_SCALE = 4;
|
||||
|
||||
/** One band per 16x, the coarsest axis worth rendering. */
|
||||
export const MIN_LOG_SCALE = -4;
|
||||
|
||||
export const MAX_NUM_BUCKETS = 512;
|
||||
|
||||
export const DEFAULT_NUM_BUCKETS = 60;
|
||||
|
||||
/** Every scale the request accepts, coarsest first. The bounds strip below says
|
||||
* how coarse a given one is, so the numbers stand alone. */
|
||||
export const LOG_SCALES = Array.from(
|
||||
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
|
||||
(_, index) => MIN_LOG_SCALE + index,
|
||||
);
|
||||
|
||||
/** How many leading upper bounds the bounds strip previews before eliding. */
|
||||
export const PREVIEW_BOUND_COUNT = 8;
|
||||
|
||||
/** The kind toggle's options. `auto` sends no options and lets the server choose. */
|
||||
export const BUCKET_KIND_OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
];
|
||||
|
||||
export const LOG_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
|
||||
value: String(scale),
|
||||
label: String(scale),
|
||||
}));
|
||||
|
||||
export const BUCKET_KIND_HINTS = {
|
||||
auto:
|
||||
'Bounds are picked for you: a log axis at scale 4, the finest the query can return.',
|
||||
log: 'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. A lower scale means fewer, coarser bands.',
|
||||
linear:
|
||||
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Querybuildertypesv5BucketOptionsDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
Querybuildertypesv5BucketOptionsLogDTO,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import {
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
MAX_LOG_SCALE,
|
||||
PREVIEW_BOUND_COUNT,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* The toggle's own vocabulary: the two kinds the request takes, plus `auto` for
|
||||
* sending no options at all and letting the server pick the axis.
|
||||
*/
|
||||
export type BucketKindOption = 'auto' | 'log' | 'linear';
|
||||
|
||||
export const isLinearBuckets = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
|
||||
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
|
||||
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
|
||||
|
||||
export const linearBuckets = (
|
||||
maxValue: number,
|
||||
numBuckets: number | null,
|
||||
): Querybuildertypesv5BucketOptionsLinearDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
|
||||
});
|
||||
|
||||
export const logBuckets = (
|
||||
scale: number,
|
||||
): Querybuildertypesv5BucketOptionsLogDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale },
|
||||
});
|
||||
|
||||
/**
|
||||
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
|
||||
* no scale, so it reads as the default too.
|
||||
*/
|
||||
export const logScaleOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number =>
|
||||
bucketOptions && !isLinearBuckets(bucketOptions)
|
||||
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
|
||||
: MAX_LOG_SCALE;
|
||||
|
||||
/** A log axis places `2^scale` bounds per doubling. */
|
||||
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
|
||||
|
||||
export const kindOptionOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): BucketKindOption => {
|
||||
if (!bucketOptions) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
return isLinearBuckets(bucketOptions) ? 'linear' : 'log';
|
||||
};
|
||||
|
||||
/**
|
||||
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
|
||||
* index 0's boundary — and a linear one at the top of its first band; both continue
|
||||
* past what the strip shows, and everything above the last one lands in the overflow
|
||||
* band the UI labels separately.
|
||||
*
|
||||
* `undefined` for a linear axis with no max value yet: without a top there is nothing
|
||||
* to divide.
|
||||
*/
|
||||
export function previewUpperBounds(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number[] | undefined {
|
||||
if (bucketOptions && isLinearBuckets(bucketOptions)) {
|
||||
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
|
||||
|
||||
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const width = maxValue / numBuckets;
|
||||
|
||||
return Array.from(
|
||||
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
|
||||
(_, index) => (index + 1) * width,
|
||||
);
|
||||
}
|
||||
|
||||
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
|
||||
|
||||
return Array.from(
|
||||
{ length: PREVIEW_BOUND_COUNT },
|
||||
(_, index) => 2 ** (index / bands),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
|
||||
* buckets than the strip holds ends where the strip does.
|
||||
*/
|
||||
export function hasBoundsBeyondPreview(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): boolean {
|
||||
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
|
||||
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
|
||||
* log axis distinguishable without printing the float in full.
|
||||
*/
|
||||
export function formatUpperBound(bound: number, unit?: string): string {
|
||||
if (unit) {
|
||||
return getYAxisFormattedValue(String(bound), unit);
|
||||
}
|
||||
|
||||
return Number(bound.toPrecision(3)).toString();
|
||||
}
|
||||
@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
[QueryBuilderField.BucketOptions]: 'bucketOptions',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const BUCKET_OPTIONS: AddOn = {
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
key: QueryBuilderField.BucketOptions,
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
@@ -196,7 +221,7 @@ function QueryAddOns({
|
||||
isForTraceOperator,
|
||||
});
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
const { handleSetQueryData, currentQuery } = useQueryBuilder();
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
@@ -210,8 +235,25 @@ function QueryAddOns({
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
if (showReduceTo) {
|
||||
addOns = [...addOns, REDUCE_TO];
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
addOns = [...addOns, BUCKET_OPTIONS];
|
||||
}
|
||||
|
||||
return addOns;
|
||||
}, [
|
||||
panelType,
|
||||
query.dataSource,
|
||||
query.aggregateAttribute?.type,
|
||||
showReduceTo,
|
||||
]);
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
@@ -392,6 +434,13 @@ function QueryAddOns({
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
|
||||
handleChangeQueryData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
@@ -559,6 +608,19 @@ function QueryAddOns({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find(
|
||||
(view) => view.key === QueryBuilderField.BucketOptions,
|
||||
) && (
|
||||
<div className="add-on-content" data-testid="bucket-options-content">
|
||||
<BucketOptions
|
||||
bucketOptions={query.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
handleSetQueryData: typeof mockHandleSetQueryData;
|
||||
currentQuery: { unit: string | undefined };
|
||||
} => ({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -330,4 +332,170 @@ describe('QueryAddOns', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
describe('bucket options', () => {
|
||||
function renderHeatmap(overrides: Partial<any> = {}): void {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('is offered on a metrics heatmap only', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on a heatmap over another signal', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.LOGS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
|
||||
'is not offered for a %s metric, which carries its own buckets',
|
||||
(type) => {
|
||||
renderHeatmap({
|
||||
aggregateAttribute: { key: 'http_duration_bucket', type },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("auto-opens on the query's own kind", () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('sends no options for Auto', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Auto' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the picked scale', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '0' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the coarser scales the request accepts below one band per doubling', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '-4' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: -4 },
|
||||
});
|
||||
});
|
||||
|
||||
it('previews the bounds the picked axis will carry', () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
const bounds = within(screen.getByTestId('bucket-options-bounds'));
|
||||
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
|
||||
expect(bounds.getByText(bound)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing for a linear axis until it has a max value', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Set a max value to see the bounds'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the linear axis once a max value is filled in', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
{ kind: 'linear', spec: { maxValue: 500 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes back to the toggle bar', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByTestId('bucket-options-close'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('bucket-options-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum QueryBuilderField {
|
||||
Limit = 'limit',
|
||||
Legend = 'legend_format',
|
||||
ReduceTo = 'reduce_to',
|
||||
BucketOptions = 'bucket_options',
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
|
||||
@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
|
||||
[QueryBuilderField.Limit]: { state: 'hidden' },
|
||||
[QueryBuilderField.Legend]: { state: 'hidden' },
|
||||
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
|
||||
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
|
||||
[QueryBuilderField.Formula]: { state: 'hidden' },
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
};
|
||||
|
||||
@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
|
||||
@@ -338,6 +338,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
|
||||
* count already draws. Sum is also what the statement builder forces on a histogram
|
||||
* heatmap whatever is asked for, so it is the only honest option to offer.
|
||||
*/
|
||||
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
},
|
||||
];
|
||||
|
||||
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('Footer utils', () => {
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
bucketOptions: undefined,
|
||||
disabled: false,
|
||||
filter: {
|
||||
expression: '',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DataSourceDropdown } from '../DataSourceDropdown';
|
||||
|
||||
const TEST_ID = 'query-data-source-selector';
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(TEST_ID);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DataSourceDropdown', () => {
|
||||
// antd's virtual list renders only the first couple of options into jsdom, so
|
||||
// each case asserts what the restriction admits and excludes, not the full list.
|
||||
it('offers the signals beyond the current one when nothing restricts it', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.METRICS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Logs' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only the signals the caller can visualize', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.METRICS}
|
||||
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Metrics' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Logs' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Traces' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops a signal that is not a data source a query can be built against', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.LOGS}
|
||||
allowedDataSources={[
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO[''],
|
||||
]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Logs' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Metrics' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -24,8 +26,12 @@ export function Formula({
|
||||
query,
|
||||
isQBV2,
|
||||
}: FormulaProps): JSX.Element {
|
||||
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
|
||||
useQueryBuilder();
|
||||
const {
|
||||
removeQueryBuilderEntityByIndex,
|
||||
handleSetFormulaData,
|
||||
panelType,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { handleChangeFormulaData } = useQueryOperations({
|
||||
index,
|
||||
@@ -73,6 +79,13 @@ export function Formula({
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: IBuilderFormula['bucketOptions']) => {
|
||||
handleChangeFormulaData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleQBV2OrderByChange = useCallback(
|
||||
(value: string) => {
|
||||
const [columnName, order] = value.split(' ');
|
||||
@@ -163,6 +176,17 @@ export function Formula({
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{/* A heatmap draws its one enabled query, which is the formula when its
|
||||
inputs are disabled — so the formula states its own bucket axis. */}
|
||||
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
|
||||
<Col span={24}>
|
||||
<BucketOptions
|
||||
bucketOptions={formula.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { Formula } from '../Formula';
|
||||
|
||||
const mockHandleChangeFormulaData = jest.fn();
|
||||
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): Record<string, unknown> => ({
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
panelType: mockPanelType,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
useQueryOperations: (): Record<string, unknown> => ({
|
||||
handleChangeFormulaData: mockHandleChangeFormulaData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
|
||||
}));
|
||||
|
||||
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
|
||||
return {
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
|
||||
render(
|
||||
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Formula bucket options', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPanelType = PANEL_TYPES.HEATMAP;
|
||||
});
|
||||
|
||||
it('offers a bucket axis on a heatmap', () => {
|
||||
renderFormula();
|
||||
|
||||
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
|
||||
renderFormula();
|
||||
|
||||
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no bucket axis on other panel types', () => {
|
||||
mockPanelType = PANEL_TYPES.TIME_SERIES;
|
||||
renderFormula();
|
||||
|
||||
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens on the axis the formula already carries', () => {
|
||||
renderFormula({
|
||||
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('writes the picked axis onto the formula', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFormula();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Log' }));
|
||||
|
||||
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -20,4 +20,6 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
};
|
||||
|
||||
@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
legend: '',
|
||||
};
|
||||
|
||||
const setupMockQueryBuilder = (): void => {
|
||||
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
handleSetFormulaData: mockHandleSetFormulaData,
|
||||
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
|
||||
setLastUsedQuery: mockSetLastUsedQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
panelType: 'time_series',
|
||||
panelType,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [defaultMockQuery, defaultMockQuery],
|
||||
@@ -332,4 +332,105 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spaceAggregationOptions for a histogram metric', () => {
|
||||
const histogramQuery: IBuilderQuery = {
|
||||
...defaultMockQuery,
|
||||
aggregateAttribute: {
|
||||
key: 'signoz_latency',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
} as BaseAutocompleteData,
|
||||
};
|
||||
|
||||
it('offers the percentiles on a time series panel', () => {
|
||||
const result = renderHookWithProps({ query: histogramQuery });
|
||||
|
||||
expect(
|
||||
result.current.spaceAggregationOptions.map((o) => o.value),
|
||||
).toStrictEqual([
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P75,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P95,
|
||||
MetricAggregateOperator.P99,
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({ query: histogramQuery });
|
||||
|
||||
expect(
|
||||
result.current.spaceAggregationOptions.map((o) => o.value),
|
||||
).toStrictEqual([MetricAggregateOperator.COUNT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('picking a histogram metric', () => {
|
||||
const histogramAttribute: BaseAutocompleteData = {
|
||||
key: 'http.client.duration.bucket',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
};
|
||||
|
||||
it('defaults the spatial aggregation to p90 on a time series panel', () => {
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
expect.objectContaining({
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
expect.objectContaining({
|
||||
spaceAggregation: MetricAggregateOperator.COUNT,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the bucket axis a gauge had chosen, since `le` is the axis now', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
query: {
|
||||
...defaultMockQuery,
|
||||
bucketOptions: { kind: 'log', spec: { scale: 2 } },
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({ bucketOptions: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
metricsGaugeSpaceAggregateOperatorOptions,
|
||||
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
|
||||
metricsHistogramSpaceAggregateOperatorOptions,
|
||||
metricsSumSpaceAggregateOperatorOptions,
|
||||
metricsUnknownSpaceAggregateOperatorOptions,
|
||||
@@ -176,6 +177,11 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
|
||||
});
|
||||
|
||||
const histogramSpaceAggregationOptions =
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
|
||||
: metricsHistogramSpaceAggregateOperatorOptions;
|
||||
|
||||
switch (aggregateAttribute?.type) {
|
||||
case ATTRIBUTE_TYPES.SUM:
|
||||
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
|
||||
@@ -185,11 +191,11 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
break;
|
||||
|
||||
case ATTRIBUTE_TYPES.HISTOGRAM:
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
|
||||
break;
|
||||
|
||||
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
|
||||
break;
|
||||
default:
|
||||
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
|
||||
@@ -298,10 +304,17 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: '',
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
// A heatmap cell holds a count of observations per `le` band, which is
|
||||
// the one option the kind offers — a percentile default would sit in the
|
||||
// selector with nothing behind it.
|
||||
spaceAggregation:
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? MetricAggregateOperator.COUNT
|
||||
: MetricAggregateOperator.P90,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
];
|
||||
newQuery.bucketOptions = undefined;
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
@@ -388,6 +401,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
index,
|
||||
handleMetricAggregateAtributeTypes,
|
||||
previousMetricInfo,
|
||||
panelType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -222,6 +222,31 @@ describe('useGetYAxisUnit', () => {
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the unit on the first render, without a settling pass', () => {
|
||||
// The real `useGetMetrics` rebuilds its array on every render; a hook that
|
||||
// stored the unit would need an extra render to settle, and would schedule one
|
||||
// after every render of the panel editor.
|
||||
mockUseGetMetrics.mockImplementation(() => ({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_1],
|
||||
}));
|
||||
|
||||
let renderCount = 0;
|
||||
const { result, rerender } = renderHook(() => {
|
||||
renderCount += 1;
|
||||
return useGetYAxisUnit();
|
||||
});
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(renderCount).toBe(1);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should return undefined when metrics have different units', async () => {
|
||||
mockUseGetMetrics.mockReturnValueOnce({
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getMetricUnits,
|
||||
useGetMetrics,
|
||||
@@ -46,7 +46,6 @@ function useGetYAxisUnit(
|
||||
},
|
||||
): UseGetYAxisUnitResult {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
|
||||
|
||||
const metricNames: string[] | null = useMemo(() => {
|
||||
// If the query type is not QUERY_BUILDER, return null
|
||||
@@ -95,27 +94,16 @@ function useGetYAxisUnit(
|
||||
[units],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If there are no metrics, set the y-axis unit to undefined
|
||||
if (units.length === 0) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
|
||||
} else if (units.length === 1 && units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
// If all metrics have the same non-empty unit, set the y-axis unit to it
|
||||
} else if (areAllMetricUnitsSame) {
|
||||
if (units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
} else {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
// If there is more than one metric and they have different units, set the y-axis unit to undefined
|
||||
} else if (units.length > 1 && !areAllMetricUnitsSame) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
|
||||
} else if (units.length === 1 && units[0] === '') {
|
||||
setYAxisUnit(undefined);
|
||||
// Derived, not stored: `useGetMetrics` rebuilds its array on every render, so a
|
||||
// state-and-effect version schedules an update after every render — the shape
|
||||
// React reports as "Maximum update depth exceeded".
|
||||
const yAxisUnit = useMemo(() => {
|
||||
// A single shared unit is the only thing a single axis can carry; metrics that
|
||||
// disagree, or that carry no unit at all, leave the axis unitless.
|
||||
if (units.length === 0 || !areAllMetricUnitsSame) {
|
||||
return undefined;
|
||||
}
|
||||
return units[0] || undefined;
|
||||
}, [units, areAllMetricUnitsSame]);
|
||||
|
||||
return { yAxisUnit, isLoading, isError };
|
||||
|
||||
78
frontend/src/lib/query/__tests__/handleQueryChange.test.ts
Normal file
78
frontend/src/lib/query/__tests__/handleQueryChange.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderFormula,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { handleQueryChange } from '../panelQuery';
|
||||
|
||||
const bucketOptions = {
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale: 0 },
|
||||
};
|
||||
|
||||
function queryWithFormulaAxis(): Query {
|
||||
const base = initialQueriesMap[DataSource.METRICS];
|
||||
const formula: IBuilderFormula = {
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
bucketOptions,
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], bucketOptions }],
|
||||
queryFormulas: [formula],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleQueryChange bucket options', () => {
|
||||
it('keeps both axes when the panel stays a heatmap', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.HEATMAP,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
});
|
||||
|
||||
// The request rejects an axis on any type but heatmap, so a switch has to shed it
|
||||
// from the query (via the field allowlist) and from the formula (by hand).
|
||||
it('drops both axes when switching to another panel type', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toBeUndefined();
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the rest of the formula intact', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryFormulas[0]).toMatchObject({
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Asks "would saving the current panel change the persisted widget spec?".
|
||||
@@ -99,6 +103,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.VALUE]: 'value';
|
||||
[PANEL_TYPES.PIE]: 'pie';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
};
|
||||
|
||||
export const panelTypeDataSourceFormValuesMap: Record<
|
||||
@@ -306,6 +311,75 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
// `functions` and `having` are dropped rather than carried: the heatmap request
|
||||
// rejects both. Every signal is listed because the map is keyed by the query's
|
||||
// own, which a switch can still be holding.
|
||||
[PANEL_TYPES.HEATMAP]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'expression',
|
||||
'aggregations',
|
||||
'bucketOptions',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.TABLE]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
@@ -538,6 +612,24 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Formulas are carried across a panel-type switch whole, not rebuilt from
|
||||
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
|
||||
* dropped by hand. The request rejects one on any type but heatmap.
|
||||
*/
|
||||
const withoutNonHeatmapBucketOptions = (
|
||||
formulas: IBuilderFormula[],
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
): IBuilderFormula[] => {
|
||||
if (newPanelType === PANEL_TYPES.HEATMAP) {
|
||||
return formulas;
|
||||
}
|
||||
|
||||
return (formulas ?? []).map(
|
||||
({ bucketOptions: _bucketOptions, ...rest }) => rest,
|
||||
);
|
||||
};
|
||||
|
||||
export function handleQueryChange(
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
supersetQuery: Query,
|
||||
@@ -581,6 +673,10 @@ export function handleQueryChange(
|
||||
|
||||
return tempQuery;
|
||||
}),
|
||||
queryFormulas: withoutNonHeatmapBucketOptions(
|
||||
supersetQuery.builder.queryFormulas,
|
||||
newPanelType,
|
||||
),
|
||||
queryTraceOperator:
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? []
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
background: var(--text-vanilla-100);
|
||||
// Reads against the panel through the 3px it overhangs the track at either end,
|
||||
// which is what carries it where the ramp happens to match it.
|
||||
background: var(--popover-foreground);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
@@ -38,7 +40,7 @@
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.keys {
|
||||
@@ -53,23 +55,20 @@
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.swatch,
|
||||
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
|
||||
// strokes in the theme's own direction — light on dark, dark on light.
|
||||
.hatchSwatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Approximates the canvas hatch painted over null cells.
|
||||
.hatchSwatch {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 2px,
|
||||
var(--text-vanilla-400) 2px 3px
|
||||
var(--muted-foreground) 2px 3px
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ export interface ColorBarProps {
|
||||
markerPosition?: number | null;
|
||||
/** What the colour encodes, e.g. "count". */
|
||||
label?: string;
|
||||
/** Keys for the two states a ramp cannot express: a hatched data gap, and a
|
||||
* genuine zero at the bottom. Without them the difference is guesswork. */
|
||||
showStateKeys?: boolean;
|
||||
/** Keys the one state a ramp cannot express: a hatched data gap. */
|
||||
showNoDataKey?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -25,7 +24,7 @@ export default function ColorBar({
|
||||
maxLabel,
|
||||
markerPosition = null,
|
||||
label,
|
||||
showStateKeys = true,
|
||||
showNoDataKey = false,
|
||||
'data-testid': testId = 'color-bar',
|
||||
}: ColorBarProps): JSX.Element | null {
|
||||
const gradient = useMemo(() => {
|
||||
@@ -64,16 +63,12 @@ export default function ColorBar({
|
||||
)}
|
||||
</div>
|
||||
<span className={Styles.label}>{maxLabel}</span>
|
||||
{showStateKeys && (
|
||||
{showNoDataKey && (
|
||||
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.hatchSwatch} />
|
||||
no data
|
||||
</span>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.swatch} style={{ background: ramp[0] }} />
|
||||
count 0
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,26 +46,17 @@ describe('ColorBar', () => {
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
|
||||
});
|
||||
|
||||
it('keys the two states a colour ramp cannot express', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
it('keys the one state a colour ramp cannot express', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showNoDataKey={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('no data')).toBeInTheDocument();
|
||||
expect(screen.getByText('count 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the count-0 key with the bottom of the ramp', () => {
|
||||
it('leaves the no-data key out unless the data has a gap', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('count 0').firstChild).toHaveStyle({
|
||||
background: RAMP[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the state keys when asked', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showStateKeys={false} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('no data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,15 +13,16 @@ export default function HeatmapBucketList({
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
|
||||
{rows.map((row) => (
|
||||
{rows.map((bucket) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: row.isHovered })}
|
||||
data-hovered={row.isHovered}
|
||||
key={bucket.row}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
|
||||
data-hovered={bucket.isHovered}
|
||||
data-testid="heatmap-tooltip-bucket-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
<span className={Styles.rowLabel}>{bucket.label}</span>
|
||||
<span className={Styles.rowSeparator} />
|
||||
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
formatCount,
|
||||
formatPercent,
|
||||
formatShare,
|
||||
HeatmapContributionRow,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
@@ -9,15 +9,15 @@ import Styles from './HeatmapTooltip.module.scss';
|
||||
/** Only shown when the cell sums more than one group. */
|
||||
export default function HeatmapContributionList({
|
||||
rows,
|
||||
groupByLabel,
|
||||
}: {
|
||||
rows: HeatmapContributionRow[];
|
||||
/** The `groupBy` keys these rows are by. */
|
||||
groupByLabel: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
|
||||
{groupByLabel && <span className={Styles.section}>{groupByLabel}</span>}
|
||||
<div className={Styles.columnHeader}>
|
||||
<span>Group</span>
|
||||
<span>Contribution</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
@@ -26,12 +26,18 @@ export default function HeatmapContributionList({
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ background: row.color }}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowSeparator} style={{ borderColor: row.color }} />
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
|
||||
<span
|
||||
className={Styles.rowShare}
|
||||
data-testid="heatmap-tooltip-contribution-share"
|
||||
>
|
||||
{formatShare(row.share)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing).
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
|
||||
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
|
||||
// as white-on-white in light mode.
|
||||
//
|
||||
// Padding lives on the sections rather than here, also matching the shared
|
||||
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
|
||||
// corner radius, so it has to reach the container edges.
|
||||
.container {
|
||||
// The list pays this less each row's own inset, so its text lands on it too.
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
@@ -32,8 +40,6 @@
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -41,29 +47,36 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
padding: var(--spacing-8) var(--gutter) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--gutter) 0;
|
||||
}
|
||||
|
||||
// Hollow ring, matching the legend's unselected marker — this names the filter the
|
||||
// grid is under, it is not a colour key.
|
||||
// Names the group the grid is under, as the legend draws a shown series; it is
|
||||
// not a colour key.
|
||||
.filterMarker {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid currentColor;
|
||||
background: currentColor;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -71,59 +84,76 @@
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
|
||||
.titleCount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
|
||||
var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.section {
|
||||
.columnHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 var(--row-inset) var(--spacing-2);
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
padding: 0 var(--spacing-2) var(--spacing-1);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--row-inset);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
|
||||
.rowHovered {
|
||||
background: var(--l3-background);
|
||||
color: var(--text-vanilla-100);
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rowLabel,
|
||||
.rowValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -133,19 +163,38 @@
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowPercent {
|
||||
.rowShare {
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
color: var(--text-vanilla-400);
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 1.5px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowSeparator,
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { HeatmapTooltipProps } from '../../../types';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import HeatmapBucketList from './HeatmapBucketList';
|
||||
import HeatmapContributionList from './HeatmapContributionList';
|
||||
import {
|
||||
@@ -15,9 +16,7 @@ import {
|
||||
formatBucketLabel,
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
formatGroupFilter,
|
||||
HeatmapTooltipBody,
|
||||
resolveGroupByLabel,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
@@ -120,7 +119,7 @@ export default function HeatmapTooltip({
|
||||
// A single enabled group out of several means the legend has isolated it.
|
||||
const isolated =
|
||||
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
|
||||
const filterLabel = formatGroupFilter(isolated);
|
||||
const filterLabel = isolated?.label ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -137,18 +136,20 @@ export default function HeatmapTooltip({
|
||||
timezone: resolvedTimezone,
|
||||
})}
|
||||
</span>
|
||||
{filterLabel && (
|
||||
<span
|
||||
className={Styles.filter}
|
||||
style={{ color: groupColor }}
|
||||
data-testid="heatmap-tooltip-filter"
|
||||
>
|
||||
<span className={Styles.filterMarker} />
|
||||
<span className={Styles.filterLabel}>{filterLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
{filterLabel && (
|
||||
<div
|
||||
className={Styles.filter}
|
||||
style={{ color: groupColor }}
|
||||
data-testid="heatmap-tooltip-filter"
|
||||
>
|
||||
<span className={Styles.filterMarker} />
|
||||
<span className={Styles.filterLabel}>{filterLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={Styles.title}>
|
||||
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
|
||||
{formatBucketLabel({
|
||||
@@ -158,6 +159,7 @@ export default function HeatmapTooltip({
|
||||
decimalPrecision,
|
||||
})}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
|
||||
{formatCount(cell.count)}
|
||||
</span>
|
||||
@@ -167,10 +169,7 @@ export default function HeatmapTooltip({
|
||||
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
|
||||
|
||||
{body === HeatmapTooltipBody.Contribution ? (
|
||||
<HeatmapContributionList
|
||||
rows={contributionRows}
|
||||
groupByLabel={resolveGroupByLabel(series)}
|
||||
/>
|
||||
<HeatmapContributionList rows={contributionRows} />
|
||||
) : (
|
||||
<HeatmapBucketList rows={bucketRows} />
|
||||
)}
|
||||
|
||||
@@ -23,8 +23,7 @@ function seriesFor(
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `service.name=${group}`,
|
||||
labels: [{ key: 'service.name', value: group }],
|
||||
label: `{service.name="${group}"}`,
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
@@ -100,7 +99,7 @@ describe('HeatmapTooltip — cell identity', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,6 +121,18 @@ describe('HeatmapTooltip — cell identity', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('names a pinned tooltip in its header, as the shared tooltip does', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the header unbadged while unpinned', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
@@ -180,13 +191,7 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('heads the breakdown with the groupBy key', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each row by value alone and orders by contribution', () => {
|
||||
it('names each row as the legend does and orders by contribution', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
@@ -199,23 +204,34 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
expect(rows[2]).toContain('cart');
|
||||
});
|
||||
|
||||
it('shows each group"s share of the cell', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
// 355 / 455 = 78%, 86 / 455 = 19%, 14 / 455 = 3.1%
|
||||
expect(rows[0]).toHaveTextContent('78%');
|
||||
expect(rows[1]).toHaveTextContent('19%');
|
||||
expect(rows[2]).toHaveTextContent('3.1%');
|
||||
});
|
||||
|
||||
it('still lists a group that contributed nothing', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
expect(rows).toHaveLength(GROUPED.length);
|
||||
expect(rows[3]).toHaveTextContent('payments');
|
||||
expect(rows[3]).toHaveTextContent('0.0%');
|
||||
expect(rows[3]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('names what the two columns hold', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByText('Group')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives each group its share of the cell alongside the count', () => {
|
||||
renderTooltip();
|
||||
|
||||
const shares = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-share')
|
||||
.map((share) => share.textContent);
|
||||
|
||||
expect(shares).toHaveLength(GROUPED.length);
|
||||
expect(
|
||||
shares.reduce((sum, share) => sum + parseInt(share ?? '0', 10), 0),
|
||||
).toBe(100);
|
||||
expect(shares[shares.length - 1]).toBe('0%');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
@@ -228,7 +244,7 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['service.name=checkout'] };
|
||||
const selected = { visibleGroups: ['{service.name="checkout"}'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
@@ -243,7 +259,7 @@ describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'service.name = checkout',
|
||||
'{service.name="checkout"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
|
||||
|
||||
const TIMEZONE = 'UTC';
|
||||
/** 2026-09-02T05:30:00Z. */
|
||||
const START = 1_788_327_000;
|
||||
|
||||
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
|
||||
const CLOSE_BOUNDS = [
|
||||
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
|
||||
0.0681567332915786,
|
||||
];
|
||||
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
|
||||
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
|
||||
|
||||
describe('formatColumnRange', () => {
|
||||
it('dates the column once while it stays inside a day', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30 → 14:30');
|
||||
});
|
||||
|
||||
it('carries the date across a column that spans days', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30 → 09/04 05:30');
|
||||
});
|
||||
|
||||
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30:00 → 05:30:30');
|
||||
});
|
||||
|
||||
it('reads the day in the panel timezone, not UTC', () => {
|
||||
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
|
||||
// crossing a date boundary there and not in UTC.
|
||||
expect(
|
||||
formatColumnRange({
|
||||
start: START,
|
||||
step: 9 * 3600,
|
||||
timezone: 'America/Los_Angeles',
|
||||
}),
|
||||
).toBe('09/01 22:30 → 09/02 07:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBucketRows', () => {
|
||||
it('identifies a row by its place on the axis, which its label cannot', () => {
|
||||
const rows = buildBucketRows({
|
||||
counts: COUNTS,
|
||||
yAxis: CLOSE_Y_AXIS,
|
||||
row: 3,
|
||||
column: 0,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
});
|
||||
|
||||
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
|
||||
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
|
||||
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,19 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import timezonePlugin from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezonePlugin);
|
||||
|
||||
/** Rows shown either side of the hovered one. */
|
||||
const NEIGHBOUR_SPAN = 2;
|
||||
/** Below this share a percentage needs a decimal to stay informative. */
|
||||
const PERCENT_DECIMAL_THRESHOLD = 10;
|
||||
/** Below this, the header needs seconds to distinguish columns. */
|
||||
const SUB_MINUTE_STEP = 60;
|
||||
|
||||
@@ -28,6 +31,9 @@ export enum HeatmapTooltipBody {
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
|
||||
* round to the same text — so this is what identifies a row. */
|
||||
row: number;
|
||||
label: string;
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
@@ -37,8 +43,8 @@ export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Share of the cell's total, 0..100. */
|
||||
percent: number;
|
||||
/** Fraction of the cell this group contributed, 0..1. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
@@ -49,8 +55,9 @@ export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
: HeatmapTooltipBody.Buckets;
|
||||
}
|
||||
|
||||
/** A cell is an interval, so a single instant would misreport which observations
|
||||
* it contains. The date is left to the x axis directly below. */
|
||||
/** A cell is an interval, so a single instant would misreport what it contains.
|
||||
* The start carries the date — the x axis prints one only where the day turns
|
||||
* over — and the end repeats it only across midnight. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
@@ -62,13 +69,15 @@ export function formatColumnRange({
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const format =
|
||||
const time =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const dated = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
return `${from.format(format)} → ${to.format(format)}`;
|
||||
const toFormat = to.isSame(from, 'day') ? time : dated;
|
||||
return `${from.format(dated)} → ${to.format(toFormat)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
@@ -96,36 +105,13 @@ export function formatCount(count: number | null): string {
|
||||
return count === null ? NO_DATA_LABEL : count.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatPercent(percent: number): string {
|
||||
return percent >= PERCENT_DECIMAL_THRESHOLD
|
||||
? `${Math.round(percent)}%`
|
||||
: `${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Names the group the grid is currently isolated to. */
|
||||
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
|
||||
if (!series) {
|
||||
return '';
|
||||
/** `<1%` rather than `0%`, so a group that did contribute never reads as one
|
||||
* that didn't. */
|
||||
export function formatShare(share: number): string {
|
||||
if (share > 0 && share < 0.005) {
|
||||
return '<1%';
|
||||
}
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels
|
||||
.map((label) => `${label.key} = ${label.value}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** The `groupBy` keys the breakdown is by. */
|
||||
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
|
||||
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
|
||||
return keys.join(', ');
|
||||
}
|
||||
|
||||
function formatSeriesValue(series: HeatmapSeries): string {
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels.map((label) => label.value).join(', ');
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
@@ -156,6 +142,7 @@ export function buildBucketRows({
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
row: index,
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
@@ -179,21 +166,25 @@ export function buildContributionRows({
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
/** The grid's one colour; there is no per-series hue. */
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
const counts = series.map((entry) => {
|
||||
const counted = series.map((entry) => {
|
||||
const point = entry.points.find((item) => item.timestamp === timestamp);
|
||||
// Absent or null contributed nothing to the sum, which is what this breaks down.
|
||||
return point?.counts[row] ?? 0;
|
||||
});
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return series
|
||||
.map((entry, index) => ({
|
||||
label: formatSeriesValue(entry),
|
||||
return {
|
||||
label: entry.label,
|
||||
color,
|
||||
count: counts[index],
|
||||
percent: total > 0 ? (counts[index] / total) * 100 : 0,
|
||||
// Absent or null contributed nothing to the sum this breaks down.
|
||||
count: point?.counts[row] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const total = counted.reduce((sum, entry) => sum + entry.count, 0);
|
||||
|
||||
return counted
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
share: total > 0 ? entry.count / total : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
@@ -16,15 +16,3 @@
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
|
||||
import Styles from './TooltipHeader.module.scss';
|
||||
|
||||
@@ -65,14 +65,7 @@ export default function TooltipHeader({
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
<>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import Styles from './TooltipPinnedBadge.module.scss';
|
||||
|
||||
export default function TooltipPinnedBadge(): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.status} data-testid="uplot-tooltip-status">
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import {
|
||||
clampColorSteps,
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
getMaxCount,
|
||||
getSmallestPositiveCount,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_OPACITY_ALPHA,
|
||||
normalizeCount,
|
||||
@@ -33,6 +36,21 @@ describe('getMaxCount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSmallestPositiveCount', () => {
|
||||
it('ignores nulls, zeros and non-finite counts', () => {
|
||||
expect(
|
||||
getSmallestPositiveCount([
|
||||
[0, null, 4],
|
||||
[Number.NaN, 2, -3],
|
||||
]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when nothing is above zero', () => {
|
||||
expect(getSmallestPositiveCount([[0, null]])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCountDomain', () => {
|
||||
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
|
||||
expect(
|
||||
@@ -40,6 +58,7 @@ describe('resolveCountDomain', () => {
|
||||
).toStrictEqual({
|
||||
min: 0,
|
||||
max: 20,
|
||||
logFloor: 5,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +68,7 @@ describe('resolveCountDomain', () => {
|
||||
).toStrictEqual({
|
||||
min: 10,
|
||||
max: 100,
|
||||
logFloor: 5,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,12 +78,32 @@ describe('resolveCountDomain', () => {
|
||||
).toStrictEqual({
|
||||
min: 50,
|
||||
max: 50,
|
||||
logFloor: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the log floor from the smallest positive count, below 1 included', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[0, 0.02, 0.8]])
|
||||
.logFloor,
|
||||
).toBeCloseTo(0.02, 6);
|
||||
});
|
||||
|
||||
it('keeps the log floor within MAX_LOG_DECADES of the max', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[1, 1e9]]).logFloor,
|
||||
).toBe(1e3);
|
||||
});
|
||||
|
||||
it('falls back to a floor of 1 for a grid without a positive count', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[null, 0]]).logFloor,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCount', () => {
|
||||
const domain = { min: 0, max: 1000 };
|
||||
const domain = { min: 0, max: 1000, logFloor: 1 };
|
||||
|
||||
it('spreads low counts on a log scale where a linear one washes them out', () => {
|
||||
const log = (count: number): number =>
|
||||
@@ -99,7 +139,7 @@ describe('normalizeCount', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 250,
|
||||
domain: { min: 0, max: 1000 },
|
||||
domain: { min: 0, max: 1000, logFloor: 1 },
|
||||
scale: HeatmapColorScale.Sqrt,
|
||||
}),
|
||||
).toBeCloseTo(0.5, 6);
|
||||
@@ -115,20 +155,43 @@ describe('normalizeCount', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 7,
|
||||
domain: { min: 7, max: 7 },
|
||||
domain: { min: 7, max: 7, logFloor: 1 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a log domain whose min and max share a decade floor', () => {
|
||||
it('spreads a log domain that sits entirely below a count of 1', () => {
|
||||
const fractional = { min: 0, max: 1, logFloor: 0.001 };
|
||||
const log = (count: number): number =>
|
||||
normalizeCount({
|
||||
count,
|
||||
domain: fractional,
|
||||
scale: HeatmapColorScale.Log,
|
||||
});
|
||||
|
||||
expect(log(0.001)).toBe(0);
|
||||
expect(log(0.1)).toBeCloseTo(2 / 3, 5);
|
||||
expect(log(1)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('reads a log scale linearly when the floor reaches the top of the domain', () => {
|
||||
const domainAtFloor = { min: 0, max: 1, logFloor: 1 };
|
||||
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 1,
|
||||
domain: { min: 0, max: 1 },
|
||||
domain: domainAtFloor,
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
).toBe(1);
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 0.5,
|
||||
domain: domainAtFloor,
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +214,7 @@ describe('createHeatmapColorResolver', () => {
|
||||
): ReturnType<typeof createHeatmapColorResolver> =>
|
||||
createHeatmapColorResolver({
|
||||
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
|
||||
domain: { min: 0, max: 1000 },
|
||||
domain: { min: 0, max: 1000, logFloor: 1 },
|
||||
isDarkMode,
|
||||
seriesColor: SERIES_COLOR,
|
||||
});
|
||||
@@ -163,9 +226,29 @@ describe('createHeatmapColorResolver', () => {
|
||||
expect(resolver.positionOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom colour, not the null treatment', () => {
|
||||
it('recedes a zero count into the surface, not the null treatment', () => {
|
||||
const dark = build();
|
||||
const light = build({}, false);
|
||||
|
||||
expect(dark.colorFor(0)).toBe(Color.BG_INK_500);
|
||||
expect(light.colorFor(0)).toBe(Color.BG_VANILLA_100);
|
||||
});
|
||||
|
||||
it('keeps the bottom of the ramp for the smallest count that occurred', () => {
|
||||
const resolver = build();
|
||||
|
||||
// Zero and the floor both sit at position 0; only the floor takes a colour.
|
||||
expect(resolver.colorFor(1)).toBe(resolver.ramp[0]);
|
||||
});
|
||||
|
||||
it('colours zero off the ramp once an explicit minimum lifts it off the bottom', () => {
|
||||
const resolver = createHeatmapColorResolver({
|
||||
options: { ...DEFAULT_HEATMAP_COLORS, minCount: 5 },
|
||||
domain: { min: 5, max: 1000, logFloor: 5 },
|
||||
isDarkMode: true,
|
||||
seriesColor: SERIES_COLOR,
|
||||
});
|
||||
|
||||
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
|
||||
});
|
||||
|
||||
@@ -202,6 +285,6 @@ describe('createHeatmapColorResolver', () => {
|
||||
});
|
||||
|
||||
it('reports the domain it applied', () => {
|
||||
expect(build().domain).toStrictEqual({ min: 0, max: 1000 });
|
||||
expect(build().domain).toStrictEqual({ min: 0, max: 1000, logFloor: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
canUseLogAxis,
|
||||
cropHeatmapYAxis,
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnAlignedSplits,
|
||||
resolveColumnIndex,
|
||||
resolveHeatmapYAxis,
|
||||
resolveRowIndex,
|
||||
@@ -57,9 +59,10 @@ describe('resolveHeatmapYAxis', () => {
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
|
||||
// Geometric mean ratio (4096/128)^(1/3) = 3.174…, two of them for the
|
||||
// overflow row.
|
||||
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (2 / 3), 6);
|
||||
});
|
||||
|
||||
it('keeps bounds in value space on a linear axis', () => {
|
||||
@@ -254,7 +257,7 @@ describe('decimateAxisSplits', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
describe('resolveHeatmapYAxis — the scale auto picks', () => {
|
||||
// The OTel SDK default explicit bucket boundaries, which start at zero.
|
||||
const OTEL = [
|
||||
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
|
||||
@@ -264,10 +267,10 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
|
||||
const PLOT_HEIGHT = 250;
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
/** Row heights in axis units, less the deliberately taller overflow row. */
|
||||
function rowHeights(bounds: number[]): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Auto);
|
||||
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
|
||||
@@ -281,25 +284,46 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
}
|
||||
|
||||
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Auto);
|
||||
|
||||
// A linear fallback would leave the boundaries untransformed.
|
||||
expect(splits).not.toStrictEqual([0, 5, 10]);
|
||||
});
|
||||
|
||||
it('is what an all-positive layout does NOT get — that stays a plain log', () => {
|
||||
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Auto);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
});
|
||||
|
||||
it('gives every row a usable height for the OTel default boundaries', () => {
|
||||
// Linear squeezes the 0–100ms buckets — where the data is — under a pixel.
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Log)).toBeGreaterThan(4);
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Auto)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
|
||||
it('gives the row above zero one bucket of height, not a whole decade', () => {
|
||||
const heights = rowHeights(OTEL);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto);
|
||||
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
|
||||
const positive = OTEL.filter((bound) => bound > 0);
|
||||
const bucket =
|
||||
(Math.log10(positive[positive.length - 1]) - Math.log10(positive[0])) /
|
||||
(positive.length - 1);
|
||||
|
||||
// One axis unit — the same space a decade gets above the threshold.
|
||||
expect(heights[nearZero]).toBeCloseTo(1, 6);
|
||||
expect(heights[nearZero]).toBeCloseTo(bucket, 6);
|
||||
});
|
||||
|
||||
it('reads a lone zero boundary as a log layout, not a zero-crossing one', () => {
|
||||
expect(resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto).edges).toStrictEqual(
|
||||
resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log).edges,
|
||||
);
|
||||
});
|
||||
|
||||
it('turns to symlog once a boundary reaches below zero', () => {
|
||||
expect(resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto).edges).toStrictEqual(
|
||||
resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Symlog).edges,
|
||||
);
|
||||
});
|
||||
|
||||
it('places boundaries either side of zero symmetrically', () => {
|
||||
@@ -308,8 +332,15 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('draws the overflow row taller than a bucket, so its edge reads as one', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
|
||||
const overflowHeight = edges[edges.length - 1] - edges[edges.length - 2];
|
||||
|
||||
expect(overflowHeight).toBeGreaterThan(Math.max(...rowHeights(SKEW)));
|
||||
});
|
||||
|
||||
it('keeps negative boundaries ascending', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
@@ -317,7 +348,7 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
it('round-trips a boundary back to its bucket value', () => {
|
||||
const { splits, toBucketValue } = resolveHeatmapYAxis(
|
||||
SKEW,
|
||||
HeatmapAxisScale.Log,
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -329,7 +360,7 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
|
||||
const { edges, rows } = resolveHeatmapYAxis(
|
||||
[-100, -10, 0, 10, 100],
|
||||
HeatmapAxisScale.Log,
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
const crossing = rows.findIndex(
|
||||
(row) => row.lower === -10 && row.upper === 0,
|
||||
@@ -342,15 +373,334 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
it('leaves an all-positive layout on a plain log axis', () => {
|
||||
const { splits } = resolveHeatmapYAxis(
|
||||
[128, 256, 1024],
|
||||
HeatmapAxisScale.Log,
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('falls back to linear when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Log);
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Auto);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
|
||||
// The low end of the OTel SDK defaults: a zero bucket, then positive bounds.
|
||||
const ZERO_HEAD = [0, 5, 10, 25];
|
||||
// Clock skew in ms — a field that straddles zero.
|
||||
const SKEW = [-100, -10, 0, 10, 100];
|
||||
|
||||
/** Row heights in axis units, less the deliberately taller overflow row. */
|
||||
function rowHeights(bounds: number[], scale: HeatmapAxisScale): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
it('log stays a plain log10 for a positive layout', () => {
|
||||
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
});
|
||||
|
||||
it('log keeps its own answer for a zero bucket rather than becoming a symlog', () => {
|
||||
const { splits } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
|
||||
// One bucket below the smallest positive bound, where a symlog would put it
|
||||
// a whole decade below.
|
||||
const gap = (Math.log10(25) - Math.log10(5)) / 2;
|
||||
|
||||
expect(splits[0]).toBeCloseTo(Math.log10(5) - gap, 6);
|
||||
expect(splits.slice(1)).toStrictEqual([5, 10, 25].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('log spends a bucket on the zero-crossing row where symlog spends a decade', () => {
|
||||
const gap = (Math.log10(25) - Math.log10(5)) / 2;
|
||||
|
||||
// Row 1 is (0, 5] — the row above the zero bucket.
|
||||
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Log)[1]).toBeCloseTo(gap, 6);
|
||||
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Symlog)[1]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('log leaves every edge ascending with a zero bucket in the layout', () => {
|
||||
const { edges } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('log squashes several non-positive boundaries onto one edge — what symlog is for', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
|
||||
// -100, -10 and 0 have no logarithm and share the floor.
|
||||
expect(edges[1]).toBe(edges[2]);
|
||||
expect(edges[2]).toBe(edges[3]);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('log has nothing to compress without a positive boundary and stays linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([-5, 0], HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual([-5, 0]);
|
||||
});
|
||||
|
||||
it('symlog is a choice for a positive layout too, and is not the log axis', () => {
|
||||
const positive = [1, 10, 100];
|
||||
|
||||
expect(
|
||||
resolveHeatmapYAxis(positive, HeatmapAxisScale.Log).splits,
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
// Threshold 1: the boundaries sit a decade apart, one unit above the linear band.
|
||||
expect(
|
||||
resolveHeatmapYAxis(positive, HeatmapAxisScale.Symlog).splits,
|
||||
).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('symlog places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW, HeatmapAxisScale.Symlog);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('symlog has no magnitude to scale against when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Symlog);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cropHeatmapYAxis', () => {
|
||||
// Four bounds, so five rows: underflow, three buckets, overflow.
|
||||
const AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
const counts = (perRow: Array<number | null>): Array<Array<number | null>> =>
|
||||
perRow.map((count) => [count]);
|
||||
|
||||
it('stops one row above the highest occupied one', () => {
|
||||
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
|
||||
|
||||
expect(cropped.max).toBe(AXIS.edges[3]);
|
||||
expect(cropped.splits).toStrictEqual(AXIS.splits.slice(0, 3));
|
||||
});
|
||||
|
||||
it('drops the infinity tick once its row is off the top', () => {
|
||||
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
|
||||
|
||||
expect(cropped.overflowSplit).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the rows intact, so a bucket is never relabelled', () => {
|
||||
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
|
||||
|
||||
expect(cropped.rows).toStrictEqual(AXIS.rows);
|
||||
expect(cropped.edges).toStrictEqual(AXIS.edges);
|
||||
});
|
||||
|
||||
it('leaves the axis alone when the top row is occupied', () => {
|
||||
expect(cropHeatmapYAxis(AXIS, counts([0, 0, 0, 0, 2]))).toStrictEqual(AXIS);
|
||||
});
|
||||
|
||||
it('leaves the axis alone when the row below the top is occupied', () => {
|
||||
expect(cropHeatmapYAxis(AXIS, counts([0, 0, 0, 9, 0]))).toStrictEqual(AXIS);
|
||||
});
|
||||
|
||||
it('treats zero and no-data cells alike — neither occupies a row', () => {
|
||||
const cropped = cropHeatmapYAxis(AXIS, counts([4, 0, null, null, null]));
|
||||
|
||||
expect(cropped.max).toBe(AXIS.edges[2]);
|
||||
});
|
||||
|
||||
it('leaves an entirely empty grid alone, having nothing to crop against', () => {
|
||||
expect(
|
||||
cropHeatmapYAxis(AXIS, counts([null, null, null, null, null])),
|
||||
).toStrictEqual(AXIS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnAlignedSplits', () => {
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
|
||||
/** uPlot's `tzDate` for a fixed-offset zone: the returned date's local fields
|
||||
* read as that zone's wall clock, whatever the machine's own zone is. */
|
||||
const zoneAt =
|
||||
(offsetSeconds: number) =>
|
||||
(timestamp: number): Date => {
|
||||
const browserOffset =
|
||||
-new Date(timestamp * 1e3).getTimezoneOffset() * MINUTE;
|
||||
return new Date((timestamp + offsetSeconds - browserOffset) * 1e3);
|
||||
};
|
||||
|
||||
const UTC = zoneAt(0);
|
||||
/** IST, whose half-hour offset is what pulls ticks off round local times. */
|
||||
const IST = zoneAt(5.5 * HOUR);
|
||||
/** 2024-03-11T00:00:00Z, a Monday. */
|
||||
const MIDNIGHT_UTC = 1_710_115_200;
|
||||
|
||||
it('lands every tick on a column edge', () => {
|
||||
const step = 90;
|
||||
const anchor = 1_700_000_010;
|
||||
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor,
|
||||
step,
|
||||
incr: 5 * MINUTE,
|
||||
min: anchor,
|
||||
max: anchor + 40 * step,
|
||||
});
|
||||
|
||||
expect(splits.length).toBeGreaterThan(1);
|
||||
splits.forEach((split) => {
|
||||
expect((split - anchor) % step).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds the increment up to a whole number of columns', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: 0,
|
||||
step: 90,
|
||||
incr: 5 * MINUTE,
|
||||
min: 0,
|
||||
max: HOUR,
|
||||
toDate: UTC,
|
||||
});
|
||||
|
||||
// 300s asked for, 360s is the next multiple of the 90s column.
|
||||
expect(splits[1] - splits[0]).toBe(360);
|
||||
});
|
||||
|
||||
it('covers the visible range without overshooting it', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: 1000,
|
||||
step: 100,
|
||||
incr: 200,
|
||||
min: 1050,
|
||||
max: 1650,
|
||||
toDate: UTC,
|
||||
});
|
||||
|
||||
expect(splits[0]).toBeGreaterThanOrEqual(1050);
|
||||
expect(splits[splits.length - 1]).toBeLessThanOrEqual(1650);
|
||||
expect(splits[0] - 200).toBeLessThan(1050);
|
||||
});
|
||||
|
||||
it("starts hourly ticks on the timezone's own hour, not the epoch's", () => {
|
||||
const args = {
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: 5 * MINUTE,
|
||||
incr: HOUR,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 3 * HOUR,
|
||||
};
|
||||
|
||||
// 05:30 IST is midnight UTC, so the two disagree by the half hour.
|
||||
expect(resolveColumnAlignedSplits({ ...args, toDate: UTC })).toStrictEqual([
|
||||
MIDNIGHT_UTC,
|
||||
MIDNIGHT_UTC + HOUR,
|
||||
MIDNIGHT_UTC + 2 * HOUR,
|
||||
MIDNIGHT_UTC + 3 * HOUR,
|
||||
]);
|
||||
expect(resolveColumnAlignedSplits({ ...args, toDate: IST })).toStrictEqual([
|
||||
MIDNIGHT_UTC + 0.5 * HOUR,
|
||||
MIDNIGHT_UTC + 1.5 * HOUR,
|
||||
MIDNIGHT_UTC + 2.5 * HOUR,
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up the round local time when no column edge carries one', () => {
|
||||
// Hour-wide columns start on the UTC hour, so 00:00 IST is mid-cell and
|
||||
// the nearest edge — 00:30 IST — is as close as the grid gets.
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: HOUR,
|
||||
incr: HOUR,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 2 * HOUR,
|
||||
toDate: IST,
|
||||
});
|
||||
|
||||
expect(splits).toStrictEqual([
|
||||
MIDNIGHT_UTC,
|
||||
MIDNIGHT_UTC + HOUR,
|
||||
MIDNIGHT_UTC + 2 * HOUR,
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts a daily tick on the local day boundary', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: 15 * MINUTE,
|
||||
incr: DAY,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 3 * DAY,
|
||||
toDate: IST,
|
||||
});
|
||||
|
||||
// 18:30 UTC the previous day is IST midnight, and 15m columns carry it.
|
||||
expect(splits).toHaveLength(3);
|
||||
splits.forEach((split) => {
|
||||
expect(IST(split).getHours()).toBe(0);
|
||||
expect(IST(split).getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('walks month ticks as calendar dates, snapped to column edges', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: DAY,
|
||||
incr: 28 * DAY,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 120 * DAY,
|
||||
toDate: UTC,
|
||||
});
|
||||
|
||||
// Month starts, not a drifting 28-day cadence that repeats a month name.
|
||||
expect(
|
||||
splits.map((split) => new Date(split * 1e3).toISOString()),
|
||||
).toStrictEqual([
|
||||
'2024-04-01T00:00:00.000Z',
|
||||
'2024-05-01T00:00:00.000Z',
|
||||
'2024-06-01T00:00:00.000Z',
|
||||
'2024-07-01T00:00:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to uPlot's own increment without a column width", () => {
|
||||
expect(
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: 0,
|
||||
incr: 5 * MINUTE,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 15 * MINUTE,
|
||||
toDate: UTC,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
MIDNIGHT_UTC,
|
||||
MIDNIGHT_UTC + 5 * MINUTE,
|
||||
MIDNIGHT_UTC + 10 * MINUTE,
|
||||
MIDNIGHT_UTC + 15 * MINUTE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('has nothing to place on an empty or inverted range', () => {
|
||||
expect(
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: 0,
|
||||
step: MINUTE,
|
||||
incr: MINUTE,
|
||||
min: 10,
|
||||
max: 10,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
expect(
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: 0,
|
||||
step: MINUTE,
|
||||
incr: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,16 @@ function createFakePlot(cursor: { left: number; top: number }): FakePlot {
|
||||
return { plot: plot as unknown as uPlot, context, setSeries, over };
|
||||
}
|
||||
|
||||
/** Cursor `top` at the centre of `row`, so naming a cell does not also mean
|
||||
* knowing how tall the rows are. */
|
||||
function topOfRow(row: number): number {
|
||||
const centre = (Y_AXIS.edges[row] + Y_AXIS.edges[row + 1]) / 2;
|
||||
return (
|
||||
PLOT_HEIGHT -
|
||||
((centre - Y_AXIS.min) / (Y_AXIS.max - Y_AXIS.min)) * PLOT_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
function createHooks(
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void,
|
||||
dimOnHover = true,
|
||||
@@ -190,7 +200,7 @@ describe('heatmap renderer — hover', () => {
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({
|
||||
left: PLOT_WIDTH / 2,
|
||||
top: PLOT_HEIGHT / 2,
|
||||
top: topOfRow(1),
|
||||
});
|
||||
|
||||
hooks.init(plot);
|
||||
@@ -233,6 +243,21 @@ describe('heatmap renderer — hover', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clips the overlay to the plot area', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
// An end cell whose bucket or time slice is only partly in view is positioned
|
||||
// past the axis; the plot area's edge is where the highlight has to stop.
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
it('collapses the dim rects when dimming is off', () => {
|
||||
const hooks = createHooks(undefined, false);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
@@ -16,9 +16,22 @@ export const DEFAULT_COLOR_STEPS = 64;
|
||||
/** Without a floor, the lowest counts read as "no data". */
|
||||
export const MIN_OPACITY_ALPHA = 0.1;
|
||||
|
||||
/** Log's bottom when the grid holds no positive count to take it from. */
|
||||
const DEFAULT_LOG_FLOOR = 1;
|
||||
|
||||
/** Widest span a single ramp is stretched over on a log scale. */
|
||||
const MAX_LOG_DECADES = 6;
|
||||
|
||||
/** Used when neither an explicit fill nor a series colour is available. */
|
||||
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
|
||||
|
||||
/** Zero takes the panel surface so an empty stretch recedes, leaving the ramp's
|
||||
* bottom to the smallest count that did occur — on a log scale the two collapse. */
|
||||
const ZERO_FILL: Record<'dark' | 'light', string> = {
|
||||
dark: DesignToken.BG_INK_500,
|
||||
light: DesignToken.BG_VANILLA_100,
|
||||
};
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
|
||||
mode: HeatmapColorMode.Palette,
|
||||
scale: HeatmapColorScale.Log,
|
||||
@@ -32,6 +45,9 @@ export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
|
||||
export interface CountDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
/** Bottom decade of the log scale: the smallest count it separates from zero.
|
||||
* Always positive, since log has no bottom otherwise. */
|
||||
logFloor: number;
|
||||
}
|
||||
|
||||
/** Highest count, ignoring `null`. 0 for an empty grid. */
|
||||
@@ -47,6 +63,40 @@ export function getMaxCount(counts: Array<Array<number | null>>): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Smallest count above zero, ignoring `null`. `null` for a grid without one. */
|
||||
export function getSmallestPositiveCount(
|
||||
counts: Array<Array<number | null>>,
|
||||
): number | null {
|
||||
let smallest: number | null = null;
|
||||
for (const row of counts) {
|
||||
for (const count of row) {
|
||||
if (
|
||||
count !== null &&
|
||||
Number.isFinite(count) &&
|
||||
count > 0 &&
|
||||
(smallest === null || count < smallest)
|
||||
) {
|
||||
smallest = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return smallest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid's own resolution: whole counts floor at 1, while a heatmap of rates or
|
||||
* ratios can live entirely below it, where a fixed floor of 1 would flatten every
|
||||
* cell onto the bottom colour. Capped at `MAX_LOG_DECADES` so one stray tiny cell
|
||||
* cannot stretch the ramp over a range nothing else occupies.
|
||||
*/
|
||||
function resolveLogFloor(
|
||||
counts: Array<Array<number | null>>,
|
||||
max: number,
|
||||
): number {
|
||||
const smallest = getSmallestPositiveCount(counts) ?? DEFAULT_LOG_FLOOR;
|
||||
return Math.max(smallest, max / 10 ** MAX_LOG_DECADES);
|
||||
}
|
||||
|
||||
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
|
||||
export function resolveCountDomain(
|
||||
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
|
||||
@@ -54,7 +104,8 @@ export function resolveCountDomain(
|
||||
): CountDomain {
|
||||
const min = options.minCount ?? 0;
|
||||
const max = options.maxCount ?? getMaxCount(counts);
|
||||
return max > min ? { min, max } : { min, max: min };
|
||||
const logFloor = resolveLogFloor(counts, max);
|
||||
return max > min ? { min, max, logFloor } : { min, max: min, logFloor };
|
||||
}
|
||||
|
||||
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
|
||||
@@ -76,13 +127,15 @@ export function normalizeCount({
|
||||
const clamped = Math.min(Math.max(count, min), max);
|
||||
|
||||
if (scale === HeatmapColorScale.Log) {
|
||||
// 0 and 1 both sit at the bottom; log of either is meaningless.
|
||||
const logMin = Math.log10(Math.max(min, 1));
|
||||
const logMax = Math.log10(Math.max(max, 1));
|
||||
if (!(logMax > logMin)) {
|
||||
return 0;
|
||||
// Anything at or below the floor sits at the bottom; log cannot place it.
|
||||
const bottom = Math.max(min, domain.logFloor);
|
||||
const logMin = Math.log10(bottom);
|
||||
const logMax = Math.log10(max);
|
||||
if (logMax > logMin) {
|
||||
return (Math.log10(Math.max(clamped, bottom)) - logMin) / (logMax - logMin);
|
||||
}
|
||||
return (Math.log10(Math.max(clamped, 1)) - logMin) / (logMax - logMin);
|
||||
// The floor already reaches the top of the domain, so there is no span to
|
||||
// spread logarithmically. Fall through rather than flatten the whole grid.
|
||||
}
|
||||
|
||||
const linear = (clamped - min) / (max - min);
|
||||
@@ -182,9 +235,15 @@ export function createHeatmapColorResolver({
|
||||
return normalizeCount({ count, domain, scale: options.scale });
|
||||
};
|
||||
|
||||
// An explicit `minCount` above zero makes it a clamped value like any other.
|
||||
const zeroFill = domain.min <= 0 ? ZERO_FILL[isDarkMode ? 'dark' : 'light'] : null;
|
||||
|
||||
return {
|
||||
positionOf,
|
||||
colorFor: (count): string | null => {
|
||||
if (count === 0 && zeroFill !== null) {
|
||||
return zeroFill;
|
||||
}
|
||||
const t = positionOf(count);
|
||||
if (t === null) {
|
||||
return null;
|
||||
|
||||
@@ -3,6 +3,10 @@ import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
|
||||
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
|
||||
const FALLBACK_LOG_RATIO = 2;
|
||||
|
||||
/** Taller than a real bucket, so the dashed edge below it reads as the end of the
|
||||
* scale and not as the panel's border. */
|
||||
const OVERFLOW_ROW_HEIGHT_RATIO = 2;
|
||||
|
||||
const EMPTY_Y_AXIS: HeatmapYAxis = {
|
||||
rows: [],
|
||||
edges: [],
|
||||
@@ -80,23 +84,69 @@ function createSymlogTransform(threshold: number): AxisTransform {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAxisTransform(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): AxisTransform {
|
||||
if (scale !== HeatmapAxisScale.Log) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
if (canUseLogAxis(bounds)) {
|
||||
return LOG_TRANSFORM;
|
||||
}
|
||||
// All-zero bounds have no magnitude to scale against.
|
||||
/** Symmetric log about the threshold the bucket layout implies. All-zero
|
||||
* boundaries have no magnitude to scale against and stay linear. */
|
||||
function resolveSymlogTransform(bounds: number[]): AxisTransform {
|
||||
if (!bounds.some((bound) => bound !== 0)) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
return createSymlogTransform(resolveLinearThreshold(bounds));
|
||||
}
|
||||
|
||||
/** One typical bucket, in axis space — the mean ratio between adjacent positive
|
||||
* boundaries, which on a geometric layout is exactly one bucket. */
|
||||
function resolveLogGap(positive: number[]): number {
|
||||
const axisFirst = Math.log10(positive[0]);
|
||||
const axisLast = Math.log10(positive[positive.length - 1]);
|
||||
const gap =
|
||||
positive.length > 1
|
||||
? (axisLast - axisFirst) / (positive.length - 1)
|
||||
: Math.log10(FALLBACK_LOG_RATIO);
|
||||
return gap > 0 ? gap : Math.log10(FALLBACK_LOG_RATIO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain log10, with the boundaries a logarithm has no answer for — zero and
|
||||
* below — pinned one bucket beneath the smallest positive one. They keep their
|
||||
* own rows, ticks and labels; only their height is synthetic, and it is the
|
||||
* height of a bucket rather than the decade a symmetric log would spend on them.
|
||||
*
|
||||
* Several of them share that one edge, which squashes them together: a layout
|
||||
* that straddles zero wants `Symlog`. This is the scale for the one non-positive
|
||||
* boundary an explicit-bounds histogram routinely carries — its zero bucket.
|
||||
*/
|
||||
function createFloorLogTransform(positive: number[]): AxisTransform {
|
||||
const floor = Math.log10(positive[0]) - resolveLogGap(positive);
|
||||
return {
|
||||
toAxisValue: (value) => (value > 0 ? Math.log10(value) : floor),
|
||||
toBucketValue: (axisValue) => 10 ** axisValue,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAxisTransform(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): AxisTransform {
|
||||
if (scale === HeatmapAxisScale.Linear) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
if (scale === HeatmapAxisScale.Symlog) {
|
||||
return resolveSymlogTransform(bounds);
|
||||
}
|
||||
if (canUseLogAxis(bounds)) {
|
||||
return LOG_TRANSFORM;
|
||||
}
|
||||
// Only a negative boundary straddles zero; the lone zero an explicit-bounds
|
||||
// histogram carries does not.
|
||||
if (scale === HeatmapAxisScale.Auto && bounds.some((bound) => bound < 0)) {
|
||||
return resolveSymlogTransform(bounds);
|
||||
}
|
||||
const positive = bounds.filter((bound) => bound > 0);
|
||||
return positive.length > 0
|
||||
? createFloorLogTransform(positive)
|
||||
: LINEAR_TRANSFORM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-ended rows still need a height, so each gets the grid's typical bucket
|
||||
* width — the mean gap in axis space, which on a geometric layout is exactly one
|
||||
@@ -115,7 +165,7 @@ function resolveOuterEdges(
|
||||
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
|
||||
// Never extend below zero unless the boundaries already do.
|
||||
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
|
||||
return { lower, upper: last + safeGap };
|
||||
return { lower, upper: last + safeGap * OVERFLOW_ROW_HEIGHT_RATIO };
|
||||
}
|
||||
|
||||
const axisFirst = transform.toAxisValue(first);
|
||||
@@ -127,7 +177,9 @@ function resolveOuterEdges(
|
||||
|
||||
return {
|
||||
lower: transform.toBucketValue(axisFirst - safeGap),
|
||||
upper: transform.toBucketValue(axisLast + safeGap),
|
||||
upper: transform.toBucketValue(
|
||||
axisLast + safeGap * OVERFLOW_ROW_HEIGHT_RATIO,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +231,48 @@ export function resolveHeatmapYAxis(
|
||||
};
|
||||
}
|
||||
|
||||
/** Highest row holding a count above zero; `null` for a grid with none. */
|
||||
function resolveTopOccupiedRow(
|
||||
counts: Array<Array<number | null>>,
|
||||
): number | null {
|
||||
for (let row = counts.length - 1; row >= 0; row -= 1) {
|
||||
if (counts[row]?.some((count) => count !== null && count > 0)) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the axis one row above the highest occupied one: a bucket layout routinely
|
||||
* runs decades past anything observed, and that tail squeezes the rows carrying the
|
||||
* distribution. Only `max` and the ticks above it move, so a cropped-away `+Inf` row
|
||||
* is never relabelled as the one below it.
|
||||
*/
|
||||
export function cropHeatmapYAxis(
|
||||
yAxis: HeatmapYAxis,
|
||||
counts: Array<Array<number | null>>,
|
||||
): HeatmapYAxis {
|
||||
const topOccupied = resolveTopOccupiedRow(counts);
|
||||
if (topOccupied === null) {
|
||||
return yAxis;
|
||||
}
|
||||
|
||||
const topVisible = Math.min(topOccupied + 1, yAxis.rows.length - 1);
|
||||
if (topVisible >= yAxis.rows.length - 1) {
|
||||
return yAxis;
|
||||
}
|
||||
|
||||
return {
|
||||
...yAxis,
|
||||
// `splits[i]` is row `i`'s upper edge, so truncating keeps the caller's
|
||||
// index-keyed labels aligned.
|
||||
splits: yAxis.splits.slice(0, topVisible + 1),
|
||||
overflowSplit: null,
|
||||
max: yAxis.edges[topVisible + 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
|
||||
export function resolveRowIndex(
|
||||
edges: number[],
|
||||
@@ -295,3 +389,149 @@ export function decimateAxisSplits({
|
||||
|
||||
return kept.reverse();
|
||||
}
|
||||
|
||||
/** Where uPlot switches from a fixed increment to a calendar walk. */
|
||||
const MONTH_INCR_SECONDS = 3600 * 24 * 28;
|
||||
const YEAR_INCR_SECONDS = 3600 * 24 * 365;
|
||||
|
||||
/** Shifts a timestamp into the axis timezone, as uPlot's `tzDate` does: the
|
||||
* returned date's *local* fields read as that timezone's wall clock. */
|
||||
type ToAxisDate = (timestamp: number) => Date;
|
||||
|
||||
const BROWSER_DATE: ToAxisDate = (timestamp) => new Date(timestamp * 1e3);
|
||||
|
||||
/**
|
||||
* Real epoch seconds of the midnight at or before `timestamp`, in the axis
|
||||
* timezone. The browser's own offset cancels: it is inside the shifted date's
|
||||
* fields and inside the correction.
|
||||
*/
|
||||
function resolveDayOrigin(timestamp: number, toDate: ToAxisDate): number {
|
||||
const shifted = toDate(timestamp);
|
||||
const midnight = new Date(
|
||||
shifted.getFullYear(),
|
||||
shifted.getMonth(),
|
||||
shifted.getDate(),
|
||||
);
|
||||
const correction = Math.floor(timestamp) - Math.floor(shifted.getTime() / 1e3);
|
||||
return Math.floor(midnight.getTime() / 1e3) + correction;
|
||||
}
|
||||
|
||||
function fromAxisDate(wall: Date, toDate: ToAxisDate): number {
|
||||
const wallTs = Math.floor(wall.getTime() / 1e3);
|
||||
return wallTs + (wallTs - Math.floor(toDate(wallTs).getTime() / 1e3));
|
||||
}
|
||||
|
||||
function snapToColumnEdge(value: number, phase: number, width: number): number {
|
||||
return phase + Math.round((value - phase) / width) * width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Month and year ticks, walked as calendar dates the way uPlot walks them — no
|
||||
* fixed increment expresses a month. Their spacing is uneven to begin with, so
|
||||
* each tick is snapped to its own nearest column edge.
|
||||
*/
|
||||
function resolveCalendarSplits({
|
||||
incr,
|
||||
min,
|
||||
max,
|
||||
toDate,
|
||||
phase,
|
||||
columnWidth,
|
||||
}: {
|
||||
incr: number;
|
||||
min: number;
|
||||
max: number;
|
||||
toDate: ToAxisDate;
|
||||
phase: number;
|
||||
columnWidth: number;
|
||||
}): number[] {
|
||||
const isYear = incr >= YEAR_INCR_SECONDS;
|
||||
const monthsPerTick = Math.max(
|
||||
1,
|
||||
isYear
|
||||
? Math.round(incr / YEAR_INCR_SECONDS) * 12
|
||||
: Math.round(incr / MONTH_INCR_SECONDS),
|
||||
);
|
||||
|
||||
const start = toDate(min);
|
||||
const baseYear = start.getFullYear();
|
||||
const baseMonth = isYear ? 0 : start.getMonth();
|
||||
|
||||
const splits: number[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const wall = new Date(baseYear, baseMonth + monthsPerTick * index, 1);
|
||||
const value = snapToColumnEdge(
|
||||
fromAxisDate(wall, toDate),
|
||||
phase,
|
||||
columnWidth,
|
||||
);
|
||||
if (value > max) {
|
||||
break;
|
||||
}
|
||||
if (value >= min && value !== splits[splits.length - 1]) {
|
||||
splits.push(value);
|
||||
}
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time ticks placed on column edges, so a vertical grid line falls in the gap
|
||||
* between two cells instead of through one. uPlot's increment is rounded up to a
|
||||
* whole number of columns, and the sequence starts at the column edge nearest
|
||||
* the timezone's midnight — the closest the grid can get to the ticks uPlot
|
||||
* would have drawn. Where midnight is itself an edge, they are those ticks.
|
||||
*/
|
||||
export function resolveColumnAlignedSplits({
|
||||
anchor,
|
||||
step,
|
||||
incr,
|
||||
min,
|
||||
max,
|
||||
toDate = BROWSER_DATE,
|
||||
}: {
|
||||
/** Any column start: every edge sits at `anchor + n * step`. */
|
||||
anchor: number;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
/** Increment uPlot picked for the axis, in seconds. */
|
||||
incr: number;
|
||||
min: number;
|
||||
max: number;
|
||||
toDate?: ToAxisDate;
|
||||
}): number[] {
|
||||
if (!(incr > 0) || !(max > min)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columnWidth = step > 0 ? step : incr;
|
||||
const phase = step > 0 ? ((anchor % step) + step) % step : 0;
|
||||
|
||||
if (incr >= MONTH_INCR_SECONDS) {
|
||||
return resolveCalendarSplits({
|
||||
incr,
|
||||
min,
|
||||
max,
|
||||
toDate,
|
||||
phase,
|
||||
columnWidth,
|
||||
});
|
||||
}
|
||||
|
||||
const tickIncr = Math.ceil(incr / columnWidth) * columnWidth;
|
||||
const origin = snapToColumnEdge(
|
||||
resolveDayOrigin(min, toDate),
|
||||
phase,
|
||||
columnWidth,
|
||||
);
|
||||
|
||||
const splits: number[] = [];
|
||||
for (let index = Math.ceil((min - origin) / tickIncr); ; index += 1) {
|
||||
const value = origin + index * tickIncr;
|
||||
if (value > max) {
|
||||
break;
|
||||
}
|
||||
splits.push(value);
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
|
||||
@@ -7,28 +7,6 @@ const EMPTY_GRID: HeatmapGrid = {
|
||||
counts: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Highest single-cell count each group reaches. Read against the same domain the
|
||||
* colour bar uses, this is where a group sits on that bar.
|
||||
*/
|
||||
export function resolveGroupPeaks(
|
||||
series: HeatmapSeries[],
|
||||
): Map<string, number> {
|
||||
const peaks = new Map<string, number>();
|
||||
series.forEach((entry) => {
|
||||
let peak = 0;
|
||||
entry.points.forEach((point) =>
|
||||
point.counts.forEach((count) => {
|
||||
if (count !== null && count > peak) {
|
||||
peak = count;
|
||||
}
|
||||
}),
|
||||
);
|
||||
peaks.set(entry.label, peak);
|
||||
});
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/** Groups the legend currently has enabled. `undefined` means all of them. */
|
||||
function resolveVisible(
|
||||
series: HeatmapSeries[],
|
||||
|
||||
@@ -130,16 +130,19 @@ export function createHeatmapHooks({
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
if (hovered?.row === row && hovered?.column === column) {
|
||||
const count =
|
||||
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null;
|
||||
// The count is part of the identity: a refetch swaps the data under a
|
||||
// stationary cursor, and the cell it points at then means something else.
|
||||
if (
|
||||
hovered?.row === row &&
|
||||
hovered?.column === column &&
|
||||
hovered?.count === count
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hovered = {
|
||||
row,
|
||||
column,
|
||||
count:
|
||||
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null,
|
||||
};
|
||||
hovered = { row, column, count };
|
||||
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
|
||||
// uPlot's own focus is disabled here: it picks the series nearest in value
|
||||
// space, and a heatmap's value is a colour, not a y coordinate.
|
||||
@@ -161,10 +164,15 @@ export function createHeatmapHooks({
|
||||
destroy: (): void => {
|
||||
overlay?.container.remove();
|
||||
overlay = null;
|
||||
hovered = null;
|
||||
hatchPattern = null;
|
||||
cachedData = null;
|
||||
cachedResolver = null;
|
||||
// A rebuilt plot starts with no cursor, so a listener still holding this
|
||||
// cell would keep drawing a hover that no longer exists.
|
||||
if (hovered !== null) {
|
||||
hovered = null;
|
||||
onHoverChange?.(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
|
||||
const container = createOverlayElement();
|
||||
container.style.inset = '0';
|
||||
container.style.display = 'none';
|
||||
// The plot area's edge, as the canvas clip is to the cells: a cell at either end
|
||||
// runs past the axis when its bucket or its time slice is only partly in view,
|
||||
// and the highlight would otherwise be drawn over the axis and the panel.
|
||||
container.style.overflow = 'hidden';
|
||||
container.setAttribute('data-testid', 'heatmap-hover-overlay');
|
||||
|
||||
const dimColor = `${
|
||||
|
||||
@@ -112,6 +112,11 @@ export function drawOverflowBoundary({
|
||||
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
|
||||
return;
|
||||
}
|
||||
// A cropped axis puts the edge off the plot, where the line would stroke over
|
||||
// the panel.
|
||||
if (yAxis.edges[overflowIndex] >= yAxis.max) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctx } = u;
|
||||
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
|
||||
|
||||
@@ -41,8 +41,18 @@ export interface HeatmapColorOptions {
|
||||
|
||||
/** Row-height distribution of the bucket axis. */
|
||||
export enum HeatmapAxisScale {
|
||||
Log = 'log',
|
||||
/** Whichever of the three below the boundaries admit: log when they are all
|
||||
* positive or sit above a zero, symmetric log when they reach below it,
|
||||
* linear when they are all zero. The choice is a property of the data, so
|
||||
* this is the default. */
|
||||
Auto = 'auto',
|
||||
Linear = 'linear',
|
||||
/** Plain log10. A boundary at or below zero has no logarithm, so it is pinned
|
||||
* one bucket below the smallest positive one — see `resolveHeatmapYAxis`. */
|
||||
Log = 'log',
|
||||
/** Linear within ±the smallest non-zero boundary, logarithmic beyond,
|
||||
* mirrored across zero. The scale for boundaries that straddle zero. */
|
||||
Symlog = 'symlog',
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesPoint {
|
||||
@@ -58,10 +68,9 @@ export interface HeatmapSeriesLabel {
|
||||
}
|
||||
|
||||
export interface HeatmapSeries {
|
||||
/** Group label, as the legend names it. Empty when there is no grouping. */
|
||||
/** Group label, as the legend and the tooltip name it. Empty when there is no
|
||||
* grouping. */
|
||||
label: string;
|
||||
/** The pairs behind `label`, letting the tooltip name rows by value alone. */
|
||||
labels?: HeatmapSeriesLabel[];
|
||||
points: HeatmapSeriesPoint[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
@@ -14,25 +14,39 @@ import {
|
||||
resolveExtremeColor,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
resolveGroupPeaks,
|
||||
resolveHeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
cropHeatmapYAxis,
|
||||
resolveHeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorMode,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { HeatmapChartProps } from 'lib/visualization/charts/types';
|
||||
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from './utils';
|
||||
import {
|
||||
buildHeatmapConfig,
|
||||
hasMissingCells,
|
||||
prepareHeatmapChartData,
|
||||
resolveBoundaryPrecision,
|
||||
resolveGroupOrder,
|
||||
} from './utils';
|
||||
|
||||
/** Vertical space the colour bar takes out of the container. */
|
||||
const COLOR_BAR_HEIGHT = 28;
|
||||
|
||||
/** Row, column and count together: a refetch changes what the cell under a
|
||||
* stationary cursor means, while the row and column stay put. */
|
||||
function isSameCell(a: HeatmapCell | null, b: HeatmapCell | null): boolean {
|
||||
if (a === null || b === null) {
|
||||
return a === b;
|
||||
}
|
||||
return a.row === b.row && a.column === b.column && a.count === b.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns are time slices, rows are bucket ranges, cell colour is the observation
|
||||
* count — so a distribution can be watched changing shape instead of collapsing to
|
||||
@@ -48,7 +62,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
axisScale = HeatmapAxisScale.Log,
|
||||
axisScale = HeatmapAxisScale.Auto,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
@@ -72,13 +86,11 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
|
||||
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
|
||||
const hoveredCellRef = useRef<HeatmapCell | null>(null);
|
||||
const hoverFrameRef = useRef<number | null>(null);
|
||||
const onCellClickRef = useRef(onCellClick);
|
||||
onCellClickRef.current = onCellClick;
|
||||
|
||||
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
|
||||
// One series has nothing to choose between.
|
||||
const hasGroupLegend = showLegend && groups.length > 1;
|
||||
const groups = useMemo(() => resolveGroupOrder(series), [series]);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
|
||||
@@ -106,8 +118,15 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
);
|
||||
|
||||
const yAxis = useMemo(
|
||||
() => resolveHeatmapYAxis(grid.bounds, axisScale),
|
||||
[grid.bounds, axisScale],
|
||||
() => cropHeatmapYAxis(resolveHeatmapYAxis(grid.bounds, axisScale), grid.counts),
|
||||
[grid.bounds, grid.counts, axisScale],
|
||||
);
|
||||
|
||||
// The axis, the series labels and the tooltip all name rows by their boundaries,
|
||||
// so they share one precision.
|
||||
const boundaryPrecision = useMemo(
|
||||
() => resolveBoundaryPrecision({ yAxis, yAxisUnit, decimalPrecision }),
|
||||
[yAxis, yAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
|
||||
@@ -133,11 +152,37 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
|
||||
// Stable: the renderer captures it at config-build time, so a new identity would
|
||||
// recreate the plot on every hover.
|
||||
//
|
||||
// The plot reports the cell from inside its own render path, which React can be
|
||||
// driving — a resize or a data swap runs the plot's hooks during a commit, and a
|
||||
// rebuilt plot re-reports the cell the cursor is still sitting on. Committing to
|
||||
// state there nests an update inside the commit that caused it, so the two feed
|
||||
// each other until React gives up at its depth limit. The frame takes the update
|
||||
// out of that chain; the equality check drops a report that carries nothing new.
|
||||
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
|
||||
hoveredCellRef.current = cell;
|
||||
setHoveredCell(cell);
|
||||
if (hoverFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
hoverFrameRef.current = requestAnimationFrame(() => {
|
||||
hoverFrameRef.current = null;
|
||||
setHoveredCell((previous) =>
|
||||
isSameCell(previous, hoveredCellRef.current)
|
||||
? previous
|
||||
: hoveredCellRef.current,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (hoverFrameRef.current !== null) {
|
||||
cancelAnimationFrame(hoverFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHeatmapConfig({
|
||||
@@ -150,7 +195,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
dimOnHover,
|
||||
onHoverChange: handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
@@ -166,7 +211,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
dimOnHover,
|
||||
handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
boundaryPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
@@ -174,11 +219,6 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
],
|
||||
);
|
||||
|
||||
// Each marker takes the ramp colour for where that group's densest cell falls on
|
||||
// the colour bar, so a swatch reads against the same scale as the grid.
|
||||
const groupPeaks = useMemo(() => resolveGroupPeaks(series), [series]);
|
||||
const isPaletteMode = colors.mode === HeatmapColorMode.Palette;
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
groups.map((group, index) => ({
|
||||
@@ -186,19 +226,12 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
// handling is identical across charts.
|
||||
seriesIndex: index + 1,
|
||||
label: group,
|
||||
color: isPaletteMode
|
||||
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
|
||||
: extremeColor,
|
||||
// Colour means count here, so a marker names its group rather than keying
|
||||
// a colour — every one of them takes the top of the ramp.
|
||||
color: extremeColor,
|
||||
show: visibleGroups.includes(group),
|
||||
})),
|
||||
[
|
||||
groups,
|
||||
visibleGroups,
|
||||
isPaletteMode,
|
||||
colorResolver,
|
||||
groupPeaks,
|
||||
extremeColor,
|
||||
],
|
||||
[groups, visibleGroups, extremeColor],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
@@ -212,7 +245,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
visibleGroups={visibleGroups}
|
||||
groupColor={extremeColor}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
decimalPrecision={boundaryPrecision}
|
||||
timezone={timezone}
|
||||
canPinTooltip={canPinTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
@@ -226,7 +259,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
visibleGroups,
|
||||
extremeColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
boundaryPrecision,
|
||||
timezone,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
@@ -263,9 +296,10 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
minLabel={colorResolver.domain.min.toLocaleString()}
|
||||
maxLabel={colorResolver.domain.max.toLocaleString()}
|
||||
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
|
||||
showNoDataKey={hasMissingCells(grid.counts)}
|
||||
/>
|
||||
);
|
||||
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
|
||||
}, [showVisualMap, hasGrid, colorResolver, hoveredCell, grid.counts]);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
@@ -276,7 +310,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={hasGroupLegend}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={groups}
|
||||
showTooltip={showTooltip}
|
||||
|
||||
@@ -3,11 +3,9 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
resolveExtremeColor,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapSeries,
|
||||
@@ -193,35 +191,18 @@ describe('Heatmap group legend', () => {
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
/** The ramp the cells and colour bar are drawn from, for the default options. */
|
||||
function activeRamp(): string[] {
|
||||
const grid = resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: SERIES,
|
||||
});
|
||||
return createHeatmapColorResolver({
|
||||
it('gives every marker the top of the palette, whatever the group"s counts', () => {
|
||||
renderHeatmap();
|
||||
// The DOM lowercases hex; the palette is built uppercase.
|
||||
const extreme = resolveExtremeColor({
|
||||
options: DEFAULT_HEATMAP_COLORS,
|
||||
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
|
||||
isDarkMode: true,
|
||||
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
|
||||
}).ramp.map((color) => color.toLowerCase());
|
||||
}
|
||||
}).toLowerCase();
|
||||
|
||||
it('places each marker where its group sits on the colour bar', () => {
|
||||
renderHeatmap();
|
||||
const ramp = activeRamp();
|
||||
|
||||
// cart peaks at 1200, checkout at 4, so cart sits further along the ramp.
|
||||
// The DOM lowercases hex; the ramp is built uppercase.
|
||||
const cart = ramp.indexOf(marker(CART).style.borderColor.toLowerCase());
|
||||
const checkout = ramp.indexOf(
|
||||
marker(CHECKOUT).style.borderColor.toLowerCase(),
|
||||
);
|
||||
|
||||
expect(cart).toBeGreaterThan(-1);
|
||||
expect(checkout).toBeGreaterThan(-1);
|
||||
expect(cart).toBeGreaterThan(checkout);
|
||||
// cart peaks at 1200 and checkout at 4, which the marker does not report.
|
||||
expect(marker(CART).style.borderColor.toLowerCase()).toBe(extreme);
|
||||
expect(marker(CHECKOUT).style.borderColor.toLowerCase()).toBe(extreme);
|
||||
});
|
||||
|
||||
it('gives every marker the solid fill in opacity mode', () => {
|
||||
@@ -236,10 +217,10 @@ describe('Heatmap group legend', () => {
|
||||
expect(marker(CART).style.borderColor).not.toBe('');
|
||||
});
|
||||
|
||||
it('hides the legend when there is only one group to choose from', () => {
|
||||
it('shows the legend for a single group, which names what the grid plots', () => {
|
||||
renderHeatmap({ series: [SERIES[0]] });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(CART)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the legend when asked', () => {
|
||||
|
||||
@@ -3,10 +3,19 @@ import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorS
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapGrid,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
|
||||
import {
|
||||
buildHeatmapConfig,
|
||||
hasMissingCells,
|
||||
prepareHeatmapChartData,
|
||||
resolveBoundaryPrecision,
|
||||
resolveGroupOrder,
|
||||
} from '../utils';
|
||||
|
||||
const GRID: HeatmapGrid = {
|
||||
bounds: [128, 256, 1024],
|
||||
@@ -67,6 +76,67 @@ function buildConfig(
|
||||
});
|
||||
}
|
||||
|
||||
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
|
||||
const CLOSE_BOUNDS = [
|
||||
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
|
||||
0.0681567332915786,
|
||||
];
|
||||
|
||||
describe('resolveBoundaryPrecision', () => {
|
||||
it('raises the precision until every boundary prints differently', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('leaves the panel"s precision alone where the boundaries already separate', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: Y_AXIS,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('never drops below the panel"s precision', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: Y_AXIS,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 4,
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('stops at the most precise the panel can express', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: resolveHeatmapYAxis(
|
||||
[0.062501, 0.062502, 0.062503],
|
||||
HeatmapAxisScale.Log,
|
||||
),
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('leaves full precision as it is, having nothing above it to reach for', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: PrecisionOptionsEnum.FULL,
|
||||
}),
|
||||
).toBe(PrecisionOptionsEnum.FULL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareHeatmapChartData', () => {
|
||||
it('puts timestamps first and one series per bucket row', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
@@ -193,3 +263,54 @@ describe('buildHeatmapConfig', () => {
|
||||
expect(hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroupOrder', () => {
|
||||
const group = (
|
||||
label: string,
|
||||
counts: Array<number | null>,
|
||||
): HeatmapSeries => ({
|
||||
label,
|
||||
points: [{ timestamp: 1000, counts }],
|
||||
});
|
||||
|
||||
it('puts the biggest contributor first', () => {
|
||||
expect(
|
||||
resolveGroupOrder([
|
||||
group('small', [1, 1]),
|
||||
group('big', [40, 2]),
|
||||
group('middling', [5, 5]),
|
||||
]),
|
||||
).toStrictEqual(['big', 'middling', 'small']);
|
||||
});
|
||||
|
||||
it('sums every column, not just the first', () => {
|
||||
expect(
|
||||
resolveGroupOrder([
|
||||
{ label: 'steady', points: [{ timestamp: 1000, counts: [5] }, { timestamp: 1060, counts: [5] }] },
|
||||
{ label: 'spike', points: [{ timestamp: 1000, counts: [9] }] },
|
||||
]),
|
||||
).toStrictEqual(['steady', 'spike']);
|
||||
});
|
||||
|
||||
it('breaks a tie alphabetically, so the order is stable across refetches', () => {
|
||||
expect(
|
||||
resolveGroupOrder([group('b', [1]), group('a', [1])]),
|
||||
).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('counts a no-data cell as no contribution', () => {
|
||||
expect(
|
||||
resolveGroupOrder([group('gaps', [null, null]), group('counts', [1])]),
|
||||
).toStrictEqual(['counts', 'gaps']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasMissingCells', () => {
|
||||
it('finds a gap anywhere in the grid', () => {
|
||||
expect(hasMissingCells([[1, 2], [3, null]])).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake a zero for a gap', () => {
|
||||
expect(hasMissingCells([[0, 0], [0, 1]])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
@@ -7,12 +7,17 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnAlignedSplits,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
createHeatmapHooks,
|
||||
HeatmapRenderOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
|
||||
import { HeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
HeatmapGrid,
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Minimum gap between y tick labels, in CSS pixels. */
|
||||
@@ -21,6 +26,79 @@ const MIN_Y_TICK_GAP_PX = 18;
|
||||
/** Label for the edge above the overflow row. */
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
|
||||
const BOUNDARY_PRECISIONS: Array<
|
||||
Exclude<PrecisionOption, PrecisionOptionsEnum.FULL>
|
||||
> = [0, 1, 2, 3, 4];
|
||||
|
||||
/** What `getYAxisFormattedValue` falls back to, so an unset precision starts
|
||||
* where the panel renders. */
|
||||
const DEFAULT_BOUNDARY_PRECISION = 2;
|
||||
|
||||
/**
|
||||
* Lowest precision, at or above the panel's, that prints every boundary
|
||||
* distinctly: `0.0625` and `0.0653` are both `0.06` at two decimals, and the rows
|
||||
* either side then claim the same range. Settles for the most precise candidate
|
||||
* when none separates every pair.
|
||||
*/
|
||||
export function resolveBoundaryPrecision({
|
||||
yAxis,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): PrecisionOption | undefined {
|
||||
if (decimalPrecision === PrecisionOptionsEnum.FULL) {
|
||||
return decimalPrecision;
|
||||
}
|
||||
|
||||
// The overflow row's upper edge is the synthetic top of the grid, labelled `∞`.
|
||||
const boundaries = yAxis.rows
|
||||
.filter((row) => !row.isOverflow)
|
||||
.map((row) => row.upper);
|
||||
|
||||
const floor = decimalPrecision ?? DEFAULT_BOUNDARY_PRECISION;
|
||||
const candidates = BOUNDARY_PRECISIONS.filter(
|
||||
(candidate) => candidate >= floor,
|
||||
);
|
||||
|
||||
const separates = (candidate: PrecisionOption): boolean => {
|
||||
const labels = boundaries.map((boundary) =>
|
||||
getToolTipValue(String(boundary), yAxisUnit, candidate),
|
||||
);
|
||||
return labels.every(
|
||||
(label, index) => index === 0 || label !== labels[index - 1],
|
||||
);
|
||||
};
|
||||
|
||||
return candidates.find(separates) ?? candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
function totalCount(entry: HeatmapSeries): number {
|
||||
let total = 0;
|
||||
entry.points.forEach((point) => {
|
||||
point.counts.forEach((count) => {
|
||||
total += count ?? 0;
|
||||
});
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Largest contributor first, as the tooltip already breaks a cell down. Response
|
||||
* order says nothing about a legend where every swatch is the same colour. */
|
||||
export function resolveGroupOrder(series: HeatmapSeries[]): string[] {
|
||||
return series
|
||||
.map((entry) => ({ label: entry.label, total: totalCount(entry) }))
|
||||
.sort((a, b) => b.total - a.total || a.label.localeCompare(b.label))
|
||||
.map((entry) => entry.label);
|
||||
}
|
||||
|
||||
/** Whether any cell is a gap rather than a count. */
|
||||
export function hasMissingCells(counts: Array<Array<number | null>>): boolean {
|
||||
return counts.some((row) => row.some((count) => count === null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
|
||||
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
|
||||
@@ -114,6 +192,16 @@ export function buildHeatmapConfig({
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
|
||||
// Grid lines are drawn on the ticks, so the ticks go on the cell edges.
|
||||
splits: (_self, _axisIdx, scaleMin, scaleMax, foundIncr): number[] =>
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: grid.timestamps[0] ?? 0,
|
||||
step: grid.step,
|
||||
incr: foundIncr,
|
||||
min: scaleMin,
|
||||
max: scaleMax,
|
||||
toDate: tzDate,
|
||||
}),
|
||||
});
|
||||
|
||||
// Ticks sit on row edges, so the overflow row is the band between the last
|
||||
|
||||
@@ -110,7 +110,8 @@ export interface HeatmapChartProps {
|
||||
isDarkMode: boolean;
|
||||
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
|
||||
colors?: Partial<HeatmapColorOptions>;
|
||||
/** Default log. */
|
||||
/** Default auto: the boundaries pick between log, symmetric log and
|
||||
* linear. */
|
||||
axisScale?: HeatmapAxisScale;
|
||||
/** Unit of the bucket boundaries; counts are always plain numbers. */
|
||||
yAxisUnit?: string;
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
// Not inline-flex: that aligns the icon's bottom edge to the row's text baseline,
|
||||
// lifting the glyph a few px.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type SegmentIconName =
|
||||
| 'pos-right'
|
||||
| 'scale-linear'
|
||||
| 'scale-log'
|
||||
| 'scale-sqrt'
|
||||
| 'interp-linear'
|
||||
| 'interp-spline'
|
||||
| 'interp-step-before'
|
||||
@@ -115,6 +116,12 @@ export function SegmentIcon({
|
||||
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
|
||||
</Svg>
|
||||
);
|
||||
case 'scale-sqrt':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2.5 13 C5.5 11, 8.5 6.5, 13.5 3" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-linear':
|
||||
return (
|
||||
<Svg>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type {
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SectionKind,
|
||||
type AnyThreshold,
|
||||
type PanelAxesSlice,
|
||||
type PanelChartAppearanceSlice,
|
||||
type PanelFormattingSlice,
|
||||
type SectionEditorProps,
|
||||
type SectionSpecMap,
|
||||
@@ -81,8 +81,8 @@ export const SECTION_REGISTRY: {
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
Component: AxesSection,
|
||||
get: (spec): DashboardtypesAxesDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
|
||||
get: (spec): PanelAxesSlice | undefined =>
|
||||
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
@@ -94,11 +94,8 @@ export const SECTION_REGISTRY: {
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
Component: ChartAppearanceSection,
|
||||
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
|
||||
spec,
|
||||
'chartAppearance',
|
||||
),
|
||||
get: (spec): PanelChartAppearanceSlice | undefined =>
|
||||
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './AxesSection.module.scss';
|
||||
|
||||
@@ -17,9 +19,37 @@ const SCALE_OPTIONS = [
|
||||
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
|
||||
];
|
||||
|
||||
// Auto is the only one that reads the boundaries rather than overriding them.
|
||||
const Y_SCALE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.auto,
|
||||
label: 'Auto',
|
||||
tooltip:
|
||||
'Log when every bucket bound is positive, symmetric log when they cross zero',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
tooltip: 'Row heights proportional to the bucket range',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.log,
|
||||
label: 'Log',
|
||||
tooltip:
|
||||
'Log-proportional row heights; a zero bucket sits one bucket below the smallest positive bound',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.symlog,
|
||||
label: 'Symmetric log',
|
||||
tooltip:
|
||||
'Linear within the smallest non-zero bound, logarithmic beyond, mirrored across zero',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
|
||||
* linear/logarithmic scale toggle. Each control is gated by its `controls` flag.
|
||||
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds, the
|
||||
* linear/logarithmic scale toggle, and the heatmap's bucket-axis distribution.
|
||||
* Each control is gated by its `controls` flag.
|
||||
*/
|
||||
function AxesSection({
|
||||
value,
|
||||
@@ -76,6 +106,19 @@ function AxesSection({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.yScale && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Y-axis scale</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-y-scale"
|
||||
placeholder="Select scale…"
|
||||
value={value?.yScale}
|
||||
items={Y_SCALE_OPTIONS}
|
||||
onChange={(next): void => onChange({ ...value, yScale: next })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AxesSection from '../AxesSection';
|
||||
@@ -64,6 +65,65 @@ describe('AxesSection', () => {
|
||||
expect(onChange).toHaveBeenCalledWith({ softMax: null });
|
||||
});
|
||||
|
||||
it('offers the bucket-axis scale instead of the soft bounds when yScale is on', () => {
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ yScale: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-y-scale')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-soft-min'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-log-scale'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers every bucket-axis scale, symmetric log included', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ yScale: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId('panel-editor-v2-y-scale');
|
||||
await user.click(
|
||||
trigger.querySelector('.ant-select-selector') as HTMLElement,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: /Auto/ }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Log/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Linear/ })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Symmetric log/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
yScale: DashboardtypesHeatmapYScaleDTO.symlog,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the scale the spec asks for', () => {
|
||||
render(
|
||||
<AxesSection
|
||||
value={{ yScale: DashboardtypesHeatmapYScaleDTO.symlog }}
|
||||
controls={{ yScale: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Symmetric log')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles the logarithmic scale through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
@@ -15,6 +15,7 @@ import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SegmentIcon } from '../../controls/segmentIcons';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
import HeatmapColorsField from './HeatmapColorsField';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
@@ -73,9 +74,9 @@ const FILL_MODE_OPTIONS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
|
||||
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
|
||||
* control is gated by its `controls` flag.
|
||||
* Edits the `chartAppearance` slice of a panel spec: line style / interpolation, fill
|
||||
* mode, point markers and the connect-null-gaps threshold for the time-axis charts,
|
||||
* the cell colour ramp for the heatmap. Each control is gated by its `controls` flag.
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
@@ -150,6 +151,13 @@ function ChartAppearanceSection({
|
||||
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.colors && (
|
||||
<HeatmapColorsField
|
||||
value={value?.colors}
|
||||
onChange={(colors): void => onChange({ ...value, colors })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
clampColorSteps,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_COLOR_STEPS,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
|
||||
import { singleSliderValue } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapColorStepsFieldProps {
|
||||
/** Unset until the slider moves; the chart's default stands in. */
|
||||
value: number | undefined;
|
||||
onChange: (steps: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many colours the ramp is quantised into. Committed on release, so a drag
|
||||
* doesn't write every value it passes through to the spec.
|
||||
*/
|
||||
function HeatmapColorStepsField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapColorStepsFieldProps): JSX.Element {
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
|
||||
const steps = dragging ?? value ?? DEFAULT_COLOR_STEPS;
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<div className={styles.fieldHeader}>
|
||||
<Typography.Text>Color steps</Typography.Text>
|
||||
<span className={styles.mono}>{steps}</span>
|
||||
</div>
|
||||
<Slider
|
||||
testId="panel-editor-v2-heatmap-color-steps"
|
||||
className={styles.slider}
|
||||
value={steps}
|
||||
min={MIN_COLOR_STEPS}
|
||||
max={MAX_COLOR_STEPS}
|
||||
step={1}
|
||||
aria-label="Color steps"
|
||||
onChange={(next): void => setDragging(singleSliderValue(next))}
|
||||
onAfterChange={(next): void => {
|
||||
setDragging(null);
|
||||
onChange(clampColorSteps(singleSliderValue(next)));
|
||||
}}
|
||||
/>
|
||||
<Typography.Text className={styles.help}>
|
||||
How many colours the ramp is split into.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapColorStepsField;
|
||||
@@ -0,0 +1,138 @@
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
// The track is inset by half a thumb at each end by default, which reads as an
|
||||
// indent against the label and help text either side of it. Radix keeps the thumb
|
||||
// in bounds on its own, so the track can run the full width of the field.
|
||||
.slider {
|
||||
--slider-track-margin-x: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fillGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.customFill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-background-60);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.customFillValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.customFillSwatch {
|
||||
width: var(--spacing-12);
|
||||
height: var(--spacing-8);
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.previewRamp {
|
||||
height: var(--spacing-8);
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.previewBounds {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.paletteGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.paletteCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-background-60);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.paletteRamp {
|
||||
height: var(--spacing-6);
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
type DashboardtypesHeatmapColorsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import HeatmapColorStepsField from './HeatmapColorStepsField';
|
||||
import HeatmapCountRangeField from './HeatmapCountRangeField';
|
||||
import HeatmapFillField from './HeatmapFillField';
|
||||
import HeatmapPaletteGrid from './HeatmapPaletteGrid';
|
||||
import HeatmapRampPreview from './HeatmapRampPreview';
|
||||
import { COLOR_MODE_OPTIONS, COLOR_SCALE_OPTIONS } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapColorsFieldProps {
|
||||
value: DashboardtypesHeatmapColorsDTO | undefined;
|
||||
onChange: (next: DashboardtypesHeatmapColorsDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits `chartAppearance.colors` — how a cell's count becomes a colour. Palette
|
||||
* mode ramps through a sequential palette, opacity mode through one fill's alpha;
|
||||
* everything below the mode applies to both.
|
||||
*/
|
||||
function HeatmapColorsField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapColorsFieldProps): JSX.Element {
|
||||
const isOpacityMode =
|
||||
value?.mode === DashboardtypesHeatmapColorModeDTO.opacity;
|
||||
|
||||
return (
|
||||
<div className={styles.colors}>
|
||||
<HeatmapRampPreview colors={value} />
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Color mode</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-heatmap-color-mode"
|
||||
value={value?.mode}
|
||||
items={COLOR_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
mode: next as DashboardtypesHeatmapColorModeDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpacityMode ? (
|
||||
<HeatmapFillField
|
||||
value={value?.fill}
|
||||
onChange={(fill): void => onChange({ ...value, fill })}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Palette</Typography.Text>
|
||||
<HeatmapPaletteGrid
|
||||
value={value?.palette}
|
||||
onChange={(palette): void => onChange({ ...value, palette })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Color scale</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-heatmap-color-scale"
|
||||
value={value?.scale}
|
||||
items={COLOR_SCALE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
scale: next as DashboardtypesHeatmapColorScaleDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.help}>
|
||||
How a count maps onto the ramp.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<HeatmapColorStepsField
|
||||
value={value?.steps || undefined}
|
||||
onChange={(steps): void => onChange({ ...value, steps })}
|
||||
/>
|
||||
|
||||
<HeatmapCountRangeField
|
||||
value={{ minCount: value?.minCount, maxCount: value?.maxCount }}
|
||||
onChange={(bounds): void => onChange({ ...value, ...bounds })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapColorsField;
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
/** `null` on either bound derives it from the data. */
|
||||
type CountBound = Pick<DashboardtypesHeatmapColorsDTO, 'minCount' | 'maxCount'>;
|
||||
|
||||
interface HeatmapCountRangeFieldProps {
|
||||
value: CountBound;
|
||||
onChange: (next: CountBound) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the colour scale to a fixed count range. Two panels rarely derive the
|
||||
* same bounds, so comparing them needs the range pinned on both.
|
||||
*/
|
||||
function HeatmapCountRangeField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapCountRangeFieldProps): JSX.Element {
|
||||
// An empty field derives the bound (null), as does transient non-numeric input
|
||||
// (a lone "-"), which pinning would read as a bound the user never typed.
|
||||
const handleBound =
|
||||
(bound: keyof CountBound) =>
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = event.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<div className={styles.bounds}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Min count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-heatmap-min-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value.minCount ?? ''}
|
||||
onChange={handleBound('minCount')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Max count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-heatmap-max-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value.maxCount ?? ''}
|
||||
onChange={handleBound('maxCount')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text className={styles.help}>
|
||||
The counts the ramp's ends mean; empty reads them off the data.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapCountRangeField;
|
||||
@@ -0,0 +1,85 @@
|
||||
import cx from 'classnames';
|
||||
import { ColorPicker } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DEFAULT_OPACITY_FILL } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
|
||||
import { FILL_PRESETS, isSameColor } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapFillFieldProps {
|
||||
/** Empty follows the selected group's legend colour. */
|
||||
value: string | undefined;
|
||||
onChange: (fill: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour opacity mode ramps the alpha of. Any hex goes, since the point of
|
||||
* the fill is matching whatever else the dashboard already uses.
|
||||
*/
|
||||
function HeatmapFillField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapFillFieldProps): JSX.Element {
|
||||
const isAuto = !value;
|
||||
const isCustom =
|
||||
!isAuto && !FILL_PRESETS.some((preset) => isSameColor(preset.color, value));
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Base color</Typography.Text>
|
||||
|
||||
<div className={styles.fillGrid}>
|
||||
{FILL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
aria-pressed={isSameColor(preset.color, value)}
|
||||
className={cx(styles.paletteCard, {
|
||||
[styles.isSelected]: isSameColor(preset.color, value),
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-fill-${preset.label}`}
|
||||
onClick={(): void => onChange(preset.color)}
|
||||
>
|
||||
<span
|
||||
className={styles.paletteRamp}
|
||||
style={{ background: preset.color }}
|
||||
/>
|
||||
<span className={styles.mono}>{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
value={value || DEFAULT_OPACITY_FILL}
|
||||
trigger="click"
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.customFill, {
|
||||
[styles.isSelected]: isCustom,
|
||||
})}
|
||||
data-testid="panel-editor-v2-heatmap-fill-custom"
|
||||
>
|
||||
<Typography.Text>Custom</Typography.Text>
|
||||
<span className={styles.customFillValue}>
|
||||
<span className={styles.mono}>
|
||||
{isAuto ? 'group colour' : value.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={styles.customFillSwatch}
|
||||
style={{ background: value || DEFAULT_OPACITY_FILL }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</ColorPicker>
|
||||
|
||||
<Typography.Text className={styles.help}>
|
||||
The colour whose alpha ramps; unset uses the group's.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapFillField;
|
||||
@@ -0,0 +1,45 @@
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesHeatmapPaletteDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { paletteGradient, PALETTE_OPTIONS } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapPaletteGridProps {
|
||||
value: DashboardtypesHeatmapPaletteDTO | undefined;
|
||||
onChange: (palette: DashboardtypesHeatmapPaletteDTO) => void;
|
||||
}
|
||||
|
||||
/** The palettes as the ramps they are: "lava" and "ember" name nothing visible. */
|
||||
function HeatmapPaletteGrid({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapPaletteGridProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className={styles.paletteGrid}>
|
||||
{PALETTE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={option.value === value}
|
||||
className={cx(styles.paletteCard, {
|
||||
[styles.isSelected]: option.value === value,
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<span
|
||||
className={styles.paletteRamp}
|
||||
style={{ background: paletteGradient(option.value, isDarkMode) }}
|
||||
/>
|
||||
<span className={styles.mono}>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapPaletteGrid;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { HeatmapColorMode } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { resolveHeatmapColors } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
|
||||
import { rampGradient } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
/** The ramp is domain-independent; the preview shows colours, not positions. */
|
||||
const NOMINAL_DOMAIN = { min: 0, max: 1, logFloor: 1 };
|
||||
|
||||
interface HeatmapRampPreviewProps {
|
||||
colors: DashboardtypesHeatmapColorsDTO | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ramp the grid will draw, resolved through the chart's own colour resolver.
|
||||
* Its ends carry the counts it is stretched between; an unset maximum is only
|
||||
* known once the data arrives.
|
||||
*/
|
||||
function HeatmapRampPreview({ colors }: HeatmapRampPreviewProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const options = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...resolveHeatmapColors(colors) }),
|
||||
[colors],
|
||||
);
|
||||
|
||||
const ramp = useMemo(
|
||||
() =>
|
||||
createHeatmapColorResolver({
|
||||
options,
|
||||
domain: NOMINAL_DOMAIN,
|
||||
isDarkMode,
|
||||
// No group to follow here, so the resolver's fallback fill stands in.
|
||||
seriesColor: '',
|
||||
}).ramp,
|
||||
[options, isDarkMode],
|
||||
);
|
||||
|
||||
const summary = [
|
||||
options.mode === HeatmapColorMode.Opacity ? 'opacity' : options.palette,
|
||||
options.scale,
|
||||
`${options.steps} steps`,
|
||||
].join(' · ');
|
||||
|
||||
return (
|
||||
<div className={styles.preview} data-testid="panel-editor-v2-heatmap-preview">
|
||||
<div className={styles.previewHeader}>
|
||||
<Typography.Text>Preview</Typography.Text>
|
||||
<span className={styles.mono}>{summary}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.previewRamp}
|
||||
style={{ background: rampGradient(ramp) }}
|
||||
/>
|
||||
<div className={styles.previewBounds}>
|
||||
<span className={styles.mono}>
|
||||
{(colors?.minCount ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.mono}>
|
||||
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapRampPreview;
|
||||
@@ -85,6 +85,36 @@ describe('ChartAppearanceSection', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the heatmap colour ramp only when the colors flag is set', () => {
|
||||
const { unmount } = render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={ALL_CONTROLS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-heatmap-color-mode'),
|
||||
).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ colors: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-color-mode'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-line-style'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen fill mode through the segmented control', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DEFAULT_COLOR_STEPS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import HeatmapColorsField from '../HeatmapColorsField';
|
||||
|
||||
const PALETTE_CARD = `panel-editor-v2-heatmap-palette-${DashboardtypesHeatmapPaletteDTO.lagoon}`;
|
||||
|
||||
describe('HeatmapColorsField', () => {
|
||||
it('shows the ramp the grid will draw, with what resolved it', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{
|
||||
palette: DashboardtypesHeatmapPaletteDTO.lava,
|
||||
scale: DashboardtypesHeatmapColorScaleDTO.log,
|
||||
steps: 32,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-preview'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('lava · log · 32 steps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the ramp ends with the counts it is stretched between', () => {
|
||||
render(
|
||||
<HeatmapColorsField value={{ maxCount: 4000 }} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
// An unset minimum always resolves to 0; an unset maximum needs the data.
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('4,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the palettes as the ramps they are in palette mode', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.palette }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(PALETTE_CARD)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-color-scale'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-color-steps'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('swaps the palettes for the base colour in opacity mode', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(PALETTE_CARD)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Base color')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the colour mode through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.palette }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Opacity'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the picked palette through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByTestId(PALETTE_CARD));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
palette: DashboardtypesHeatmapPaletteDTO.lagoon,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the palette the spec asks for as the selected one', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ palette: DashboardtypesHeatmapPaletteDTO.lagoon }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(PALETTE_CARD)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the colour scale through onChange and says what it drives', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/How a count maps onto the ramp/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Sqrt'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the default for a zero step count, which is how the wire sends none', () => {
|
||||
render(<HeatmapColorsField value={{ steps: 0 }} onChange={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(`lava · log · ${DEFAULT_COLOR_STEPS} steps`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(String(DEFAULT_COLOR_STEPS))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('steps off the default step count, which stands in until it is moved', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
String(DEFAULT_COLOR_STEPS),
|
||||
);
|
||||
|
||||
screen.getByRole('slider').focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
steps: DEFAULT_COLOR_STEPS + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot be taken past the step counts the chart can draw', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={{ steps: 2 }} onChange={onChange} />);
|
||||
|
||||
const slider = screen.getByRole('slider');
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '2');
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '128');
|
||||
|
||||
// Already at the floor, so there is no step to take and nothing to write.
|
||||
slider.focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a pinned count bound through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-heatmap-max-count'), '5');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ maxCount: 5 });
|
||||
});
|
||||
|
||||
it('takes no letters, which would silently read as no bound at all', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
const field = screen.getByTestId('panel-editor-v2-heatmap-min-count');
|
||||
await user.type(field, 'dsd');
|
||||
|
||||
expect(field).toHaveValue(null);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a count bound to null, which asks for the derived one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={{ maxCount: 500 }} onChange={onChange} />);
|
||||
|
||||
await user.clear(screen.getByTestId('panel-editor-v2-heatmap-max-count'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ maxCount: null });
|
||||
});
|
||||
|
||||
it('writes a preset base colour through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-heatmap-fill-forest'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
fill: Color.BG_FOREST_400,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the preset the spec asks for, whatever case its hex is in', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
fill: Color.BG_FOREST_400.toUpperCase(),
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-forest'),
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('shows a fill that is no preset as the custom one', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
fill: '#0060e6',
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('#0060E6')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('names the group colour as the fill until one is picked', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('group colour')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { HEATMAP_PALETTE_MAP } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import { getPaletteStops } from 'lib/uPlotV2/plugins/HeatmapPlugin/palettes';
|
||||
|
||||
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
export const COLOR_MODE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{ value: DashboardtypesHeatmapColorModeDTO.palette, label: 'Palette' },
|
||||
{ value: DashboardtypesHeatmapColorModeDTO.opacity, label: 'Opacity' },
|
||||
];
|
||||
|
||||
// Log first: bucket counts routinely span decades.
|
||||
export const COLOR_SCALE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.log,
|
||||
label: 'Log',
|
||||
icon: 'scale-log',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
label: 'Sqrt',
|
||||
icon: 'scale-sqrt',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
icon: 'scale-linear',
|
||||
},
|
||||
];
|
||||
|
||||
export interface HeatmapFillPreset {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Opacity-mode base colours: the palette values that read on both themes. */
|
||||
export const FILL_PRESETS: HeatmapFillPreset[] = [
|
||||
{ label: 'robin', color: Color.BG_ROBIN_400 },
|
||||
{ label: 'aqua', color: Color.BG_AQUA_400 },
|
||||
{ label: 'forest', color: Color.BG_FOREST_400 },
|
||||
{ label: 'sienna', color: Color.BG_SIENNA_400 },
|
||||
];
|
||||
|
||||
/** Hex comparison for a spec value that may differ only in case. */
|
||||
export function isSameColor(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): boolean {
|
||||
return Boolean(left) && left?.toLowerCase() === right?.toLowerCase();
|
||||
}
|
||||
|
||||
export interface HeatmapPaletteOption {
|
||||
value: DashboardtypesHeatmapPaletteDTO;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const PALETTE_OPTIONS: HeatmapPaletteOption[] = [
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.ice, label: 'ice' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.moss, label: 'moss' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.rust, label: 'rust' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.graphite, label: 'graphite' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.ember, label: 'ember' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.lagoon, label: 'lagoon' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.orchid, label: 'orchid' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.verdant, label: 'verdant' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.lava, label: 'lava' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.beacon, label: 'beacon' },
|
||||
];
|
||||
|
||||
/** `background` for a palette's card, low-count-first like the colour bar. */
|
||||
export function paletteGradient(
|
||||
palette: DashboardtypesHeatmapPaletteDTO,
|
||||
isDarkMode: boolean,
|
||||
): string {
|
||||
const stops = getPaletteStops(HEATMAP_PALETTE_MAP[palette], isDarkMode);
|
||||
return `linear-gradient(90deg, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
/** `background` for a resolved ramp: hard-edged bands, one per colour step. */
|
||||
export function rampGradient(ramp: string[]): string {
|
||||
if (ramp.length === 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => [
|
||||
`${color} ${(index / ramp.length) * 100}%`,
|
||||
`${color} ${((index + 1) / ramp.length) * 100}%`,
|
||||
]);
|
||||
return `linear-gradient(90deg, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
/** The Slider emits a pair in range mode; these are all single-thumb. */
|
||||
export function singleSliderValue(value: number | number[]): number {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import { toPanelType } from '../../Panels/types/panelKind';
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
/** The edited kind's definition — drives supported query types, the signals the
|
||||
* builder may be pointed at, and how it narrows the builder's fields. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
@@ -98,6 +99,7 @@ function PanelEditorQueryBuilder({
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
allowedDataSources={panelDefinition.supportedSignals}
|
||||
showTraceOperator={!isRawQuery}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
|
||||
@@ -59,6 +59,7 @@ function lastQueryBuilderProps(): {
|
||||
isRawQuery: boolean;
|
||||
showTraceOperator: boolean;
|
||||
fieldsConfig: unknown;
|
||||
allowedDataSources: string[];
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
@@ -119,6 +120,15 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
expect(props.fieldsConfig).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('hands the fields the Heatmap hides to the builder', () => {
|
||||
renderBuilder('signoz/HeatmapPanel');
|
||||
|
||||
expect(lastQueryBuilderProps().fieldsConfig).toStrictEqual({
|
||||
functions: { state: 'hidden' },
|
||||
having: { state: 'hidden' },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks List raw and leaves the field surface to the raw baseline', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
|
||||
@@ -129,3 +139,38 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
expect(props.fieldsConfig).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities guard)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('offers metrics alone for the Heatmap kind — the only signal with a bucket axis', () => {
|
||||
renderBuilder('signoz/HeatmapPanel');
|
||||
|
||||
expect(lastQueryBuilderProps().allowedDataSources).toStrictEqual(['metrics']);
|
||||
});
|
||||
|
||||
it('offers logs and traces for the List kind, which reads raw rows', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
|
||||
expect(lastQueryBuilderProps().allowedDataSources).toStrictEqual([
|
||||
'logs',
|
||||
'traces',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers every signal for a kind that visualizes them all', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(lastQueryBuilderProps().allowedDataSources).toStrictEqual([
|
||||
'metrics',
|
||||
'logs',
|
||||
'traces',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,151 @@ describe('usePanelTypeSwitch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [{ queryName: 'A' }, { queryName: 'B' }],
|
||||
queryFormulas: [{ queryName: 'F1' }],
|
||||
},
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/HeatmapPanel'));
|
||||
|
||||
const [persisted] = mockToPerses.mock.calls[0] as [Query];
|
||||
expect(persisted.builder.queryData.map((qd) => qd.queryName)).toStrictEqual([
|
||||
'A',
|
||||
]);
|
||||
expect(persisted.builder.queryFormulas).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
spaceAggregation: 'p90',
|
||||
aggregations: [{ metricName: 'signoz_latency', spaceAggregation: 'p90' }],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/HeatmapPanel'));
|
||||
|
||||
const [persisted] = mockToPerses.mock.calls[0] as [Query];
|
||||
const [queryData] = persisted.builder.queryData;
|
||||
expect(queryData.spaceAggregation).toBe('count');
|
||||
expect(queryData.aggregations?.[0]).toStrictEqual({
|
||||
metricName: 'signoz_latency',
|
||||
spaceAggregation: 'count',
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregateAttribute: { key: 'signoz_latency', type: 'Histogram' },
|
||||
spaceAggregation: 'count',
|
||||
aggregations: [
|
||||
{ metricName: 'signoz_latency', spaceAggregation: 'count' },
|
||||
],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.HEATMAP,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
|
||||
|
||||
const [persisted] = mockToPerses.mock.calls[0] as [Query];
|
||||
const [queryData] = persisted.builder.queryData;
|
||||
expect(queryData.spaceAggregation).toBe('p90');
|
||||
expect(queryData.aggregations?.[0]).toStrictEqual({
|
||||
metricName: 'signoz_latency',
|
||||
spaceAggregation: 'p90',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregateAttribute: { key: 'signoz_calls_total', type: 'Sum' },
|
||||
spaceAggregation: 'sum',
|
||||
aggregations: [
|
||||
{ metricName: 'signoz_calls_total', spaceAggregation: 'sum' },
|
||||
],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.HEATMAP,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
|
||||
|
||||
const [persisted] = mockToPerses.mock.calls[0] as [Query];
|
||||
expect(persisted.builder.queryData[0].spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, type PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
type PartialPanelTypes,
|
||||
@@ -14,9 +14,14 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
OrderByPayload,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type {
|
||||
MetricAggregation,
|
||||
SpaceAggregation,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
@@ -32,6 +37,24 @@ const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
];
|
||||
|
||||
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
|
||||
'p50',
|
||||
'p75',
|
||||
'p90',
|
||||
'p95',
|
||||
'p99',
|
||||
]);
|
||||
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
|
||||
|
||||
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
|
||||
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
|
||||
|
||||
function withDefaultListOrder(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
@@ -46,6 +69,93 @@ function withDefaultListOrder(query: Query): Query {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
|
||||
* query and refuses a formula over the cells. The per-kind cache restores whatever is
|
||||
* dropped here if the panel switches back.
|
||||
*
|
||||
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
|
||||
* so a percentile draws the grid a count already draws, and the statement builder sums
|
||||
* the `le` counts either way.
|
||||
*/
|
||||
function withSingleHeatmapQuery(query: Query): Query {
|
||||
const [first] = query.builder.queryData;
|
||||
if (!first) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...first,
|
||||
spaceAggregation: withoutPercentile(first.spaceAggregation),
|
||||
aggregations: first.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutPercentile(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
|
||||
function withoutPercentile<T extends string | undefined>(
|
||||
spaceAggregation: T,
|
||||
): T {
|
||||
return (
|
||||
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
|
||||
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
|
||||
* so a count carried out of a heatmap would sit in the selector with no option behind
|
||||
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
|
||||
*/
|
||||
function withPercentileHistogramAggregation(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((queryData) => {
|
||||
if (
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
return queryData;
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
spaceAggregation: withoutCount(queryData.spaceAggregation),
|
||||
aggregations: queryData.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutCount(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
|
||||
return (
|
||||
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
@@ -156,10 +266,14 @@ export function usePanelTypeSwitch({
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const nextQuery =
|
||||
let nextQuery =
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
nextQuery =
|
||||
newKind === 'signoz/HeatmapPanel'
|
||||
? withSingleHeatmapQuery(nextQuery)
|
||||
: withPercentileHistogramAggregation(nextQuery);
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
|
||||
@@ -22,13 +22,14 @@ import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
|
||||
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
|
||||
const { time_series, scalar, raw, heatmap } = Querybuildertypesv5RequestTypeDTO;
|
||||
|
||||
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/ListPanel': [QUERY_BUILDER],
|
||||
@@ -41,6 +42,8 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/BarChartPanel': [metrics, logs, traces],
|
||||
'signoz/NumberPanel': [metrics, logs, traces],
|
||||
'signoz/HistogramPanel': [metrics, logs, traces],
|
||||
// A heatmap needs a bucket axis, which only a metric carries.
|
||||
'signoz/HeatmapPanel': [metrics],
|
||||
'signoz/PieChartPanel': [metrics, logs, traces],
|
||||
'signoz/TablePanel': [metrics, logs, traces],
|
||||
// List renders raw rows; metrics produce no row data.
|
||||
@@ -77,6 +80,15 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
// Only Heatmap asks for `heatmap`: server-bucketed counts, one set per timestamp. Like
|
||||
// Bar it draws one mark per point, so it asks for a widened step interval too.
|
||||
'signoz/HeatmapPanel': {
|
||||
requestType: heatmap,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: true,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/NumberPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesHeatmapPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Heatmap from 'lib/visualization/charts/Heatmap/Heatmap';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
getExecStats,
|
||||
getHeatmapResults,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveHeatmapAxisScale,
|
||||
resolveHeatmapColors,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
|
||||
import {
|
||||
prepareHeatmapData,
|
||||
resolveHeatmapStep,
|
||||
resolveRequestedBucketKind,
|
||||
} from './prepareData';
|
||||
|
||||
function HeatmapPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
}: PanelRendererProps<'signoz/HeatmapPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const spec = useMemo<DashboardtypesHeatmapPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const { buckets, series, queryName } = useMemo(
|
||||
() =>
|
||||
prepareHeatmapData({
|
||||
results: getHeatmapResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const step = useMemo(
|
||||
() =>
|
||||
resolveHeatmapStep({
|
||||
series,
|
||||
stepInterval: getExecStats(data.response)?.stepIntervals?.[queryName],
|
||||
}),
|
||||
[series, queryName, data.response],
|
||||
);
|
||||
|
||||
// From the request that produced the data, so the grid keeps the window it
|
||||
// fetched while a newly zoomed one is in flight.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
// The chart rebuilds its plot when `colors` changes identity.
|
||||
const colors = useMemo(
|
||||
() => resolveHeatmapColors(spec.chartAppearance?.colors),
|
||||
[spec.chartAppearance?.colors],
|
||||
);
|
||||
|
||||
const axisScale = useMemo(
|
||||
() =>
|
||||
resolveHeatmapAxisScale(
|
||||
spec.axes?.yScale,
|
||||
resolveRequestedBucketKind(data.requestPayload),
|
||||
),
|
||||
[spec.axes?.yScale, data.requestPayload],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const legendPosition = useMemo(
|
||||
() => resolveLegendPosition(spec.legend?.position),
|
||||
[spec.legend?.position],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
),
|
||||
[panelId],
|
||||
);
|
||||
|
||||
const hasGrid = buckets.length > 0 && series.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="heatmap-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!hasGrid && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{hasGrid &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<Heatmap
|
||||
key={panelId}
|
||||
id={panelId}
|
||||
buckets={buckets}
|
||||
series={series}
|
||||
step={step}
|
||||
colors={colors}
|
||||
axisScale={axisScale}
|
||||
yAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
legendPosition={legendPosition}
|
||||
timezone={timezone}
|
||||
isDarkMode={isDarkMode}
|
||||
canPinTooltip
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
minTimeScale={minTimeScale}
|
||||
maxTimeScale={maxTimeScale}
|
||||
onDragSelect={onDragSelect}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapPanelRenderer;
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5TimeSeriesDataDTO,
|
||||
Querybuildertypesv5TimeSeriesDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
FALLBACK_STEP_SECONDS,
|
||||
prepareHeatmapData,
|
||||
resolveHeatmapStep,
|
||||
resolveRequestedBucketKind,
|
||||
} from '../prepareData';
|
||||
|
||||
/** One result carrying a single aggregation with `buckets` on its meta. */
|
||||
function result({
|
||||
queryName = 'A',
|
||||
buckets = [1, 2, 4],
|
||||
series = [],
|
||||
}: {
|
||||
queryName?: string;
|
||||
buckets?: number[];
|
||||
series?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
} = {}): Querybuildertypesv5TimeSeriesDataDTO {
|
||||
return { queryName, aggregations: [{ index: 0, meta: { buckets }, series }] };
|
||||
}
|
||||
|
||||
describe('prepareHeatmapData', () => {
|
||||
it('reads the bucket bounds and pivots each series into seconds', () => {
|
||||
const { buckets, series, queryName } = prepareHeatmapData({
|
||||
results: [
|
||||
result({
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'redis' }],
|
||||
values: [
|
||||
{ timestamp: 1_700_000_000_000, values: [1, 2, 0, 3] },
|
||||
{ timestamp: 1_700_000_060_000, values: [0, 0, 0, 0] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
legendMap: {},
|
||||
});
|
||||
|
||||
expect(buckets).toStrictEqual([1, 2, 4]);
|
||||
expect(queryName).toBe('A');
|
||||
expect(series).toStrictEqual([
|
||||
{
|
||||
label: '{service="redis"}',
|
||||
points: [
|
||||
{ timestamp: 1_700_000_000, counts: [1, 2, 0, 3] },
|
||||
{ timestamp: 1_700_000_060, counts: [0, 0, 0, 0] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('names an ungrouped series after its query, so the legend can name it', () => {
|
||||
const { series } = prepareHeatmapData({
|
||||
results: [
|
||||
result({ series: [{ values: [{ timestamp: 0, values: [1, 0, 0, 0] }] }] }),
|
||||
],
|
||||
legendMap: { A: '' },
|
||||
});
|
||||
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].label).toBe('A');
|
||||
});
|
||||
|
||||
it('applies the query legend as the group label template', () => {
|
||||
const { series } = prepareHeatmapData({
|
||||
results: [
|
||||
result({
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'host' }, value: 'web-1' }],
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
legendMap: { A: '{{host}}' },
|
||||
});
|
||||
|
||||
expect(series[0].label).toBe('web-1');
|
||||
});
|
||||
|
||||
it('keeps 0 a count and turns a non-finite one into "no data"', () => {
|
||||
const { series } = prepareHeatmapData({
|
||||
results: [
|
||||
result({
|
||||
series: [
|
||||
{ values: [{ timestamp: 0, values: [0, Number.NaN, Infinity, 2] }] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
legendMap: {},
|
||||
});
|
||||
|
||||
expect(series[0].points[0].counts).toStrictEqual([0, null, null, 2]);
|
||||
});
|
||||
|
||||
it('skips aggregations with no bounds — those carry no bucket axis', () => {
|
||||
const { buckets, queryName } = prepareHeatmapData({
|
||||
results: [
|
||||
{ queryName: 'A', aggregations: [{ index: 0, series: [] }] },
|
||||
result({ queryName: 'B', buckets: [8, 16] }),
|
||||
],
|
||||
legendMap: {},
|
||||
});
|
||||
|
||||
expect(buckets).toStrictEqual([8, 16]);
|
||||
expect(queryName).toBe('B');
|
||||
});
|
||||
|
||||
it('answers empty for a response with no results', () => {
|
||||
expect(prepareHeatmapData({ results: [], legendMap: {} })).toStrictEqual({
|
||||
buckets: [],
|
||||
series: [],
|
||||
queryName: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapStep', () => {
|
||||
const series = [
|
||||
{
|
||||
label: '',
|
||||
points: [
|
||||
{ timestamp: 100, counts: [1] },
|
||||
{ timestamp: 130, counts: [1] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('prefers the step the server reports', () => {
|
||||
expect(resolveHeatmapStep({ series, stepInterval: 60 })).toBe(60);
|
||||
});
|
||||
|
||||
it('falls back to the first positive gap between columns', () => {
|
||||
expect(resolveHeatmapStep({ series, stepInterval: undefined })).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to a minute when nothing says how wide a column is', () => {
|
||||
expect(
|
||||
resolveHeatmapStep({
|
||||
series: [{ label: '', points: [{ timestamp: 100, counts: [1] }] }],
|
||||
stepInterval: 0,
|
||||
}),
|
||||
).toBe(FALLBACK_STEP_SECONDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRequestedBucketKind', () => {
|
||||
const request = (
|
||||
queries: unknown[],
|
||||
): Querybuildertypesv5QueryRangeRequestDTO =>
|
||||
({
|
||||
compositeQuery: { queries },
|
||||
} as Querybuildertypesv5QueryRangeRequestDTO);
|
||||
|
||||
it('reads the kind off the query that carries a bucket axis', () => {
|
||||
expect(
|
||||
resolveRequestedBucketKind(
|
||||
request([
|
||||
{ spec: { name: 'A' } },
|
||||
{
|
||||
spec: {
|
||||
name: 'B',
|
||||
bucketOptions: {
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
spec: { maxValue: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(Querybuildertypesv5BucketOptionsLinearDTOKind.linear);
|
||||
});
|
||||
|
||||
it('reads a log axis too', () => {
|
||||
expect(
|
||||
resolveRequestedBucketKind(
|
||||
request([
|
||||
{
|
||||
spec: {
|
||||
bucketOptions: {
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale: 4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(Querybuildertypesv5BucketOptionsLogDTOKind.log);
|
||||
});
|
||||
|
||||
it('leaves the choice to the server when no query asked for an axis', () => {
|
||||
expect(resolveRequestedBucketKind(request([{ spec: {} }]))).toBeUndefined();
|
||||
expect(resolveRequestedBucketKind(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
|
||||
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/HeatmapPanel'> = {
|
||||
kind: 'signoz/HeatmapPanel',
|
||||
displayName: 'Heatmap',
|
||||
mode: 'query',
|
||||
icon: LayoutGrid,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
// Only metrics carry a bucket axis; the request rejects the other signals.
|
||||
supportedSignals: [TelemetrytypesSignalDTO.metrics],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
// The request rejects both: a point is a count per bucket, not a single value.
|
||||
queryBuilderFields: {
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
[QueryBuilderField.Having]: { state: 'hidden' },
|
||||
},
|
||||
// Every timestamp is a full column of cells, so the request asks for a step interval
|
||||
// wide enough to keep the grid legible: at raw resolution a multi-day range is tens of
|
||||
// thousands of cells, each of them sub-pixel.
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.heatmap,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: true,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
// A distribution per timestamp has no single value to threshold.
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
Querybuildertypesv5BucketOptionsDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5TimeSeriesDataDTO,
|
||||
Querybuildertypesv5TimeSeriesDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import type {
|
||||
HeatmapSeries,
|
||||
HeatmapSeriesLabel,
|
||||
HeatmapSeriesPoint,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
const MS_PER_SECOND = 1000;
|
||||
|
||||
export const FALLBACK_STEP_SECONDS = 60;
|
||||
|
||||
export interface HeatmapPanelData {
|
||||
/** Ascending bucket upper bounds shared by every group; empty means no grid. */
|
||||
buckets: number[];
|
||||
series: HeatmapSeries[];
|
||||
/** The key the grid's step interval is reported under. */
|
||||
queryName: string;
|
||||
}
|
||||
|
||||
const EMPTY_DATA: HeatmapPanelData = {
|
||||
buckets: [],
|
||||
series: [],
|
||||
queryName: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* V5 heatmap results → the chart's `buckets` + `series`. Each point carries one
|
||||
* count per bucket bound plus the trailing open-above overflow.
|
||||
*
|
||||
* Only the first aggregation carrying bounds is read: each comes back with its own
|
||||
* bucket axis, and a grid has one y axis.
|
||||
*/
|
||||
export function prepareHeatmapData({
|
||||
results,
|
||||
legendMap,
|
||||
}: {
|
||||
results: Querybuildertypesv5TimeSeriesDataDTO[];
|
||||
legendMap: Record<string, string>;
|
||||
}): HeatmapPanelData {
|
||||
for (const result of results) {
|
||||
const queryName = result.queryName ?? '';
|
||||
for (const aggregation of result.aggregations ?? []) {
|
||||
const buckets = aggregation.meta?.buckets;
|
||||
if (!buckets?.length) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
buckets,
|
||||
queryName,
|
||||
series: (aggregation.series ?? []).map((series) =>
|
||||
toHeatmapSeries(series, queryName, legendMap[queryName] ?? ''),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return EMPTY_DATA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket axis the request asked for; `undefined` leaves the choice to the server. A
|
||||
* heatmap draws one query, so the first envelope carrying `bucketOptions` is it —
|
||||
* every variant declares it at the same place, which the union cannot express.
|
||||
*/
|
||||
export function resolveRequestedBucketKind(
|
||||
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): Querybuildertypesv5BucketOptionsDTO['kind'] | undefined {
|
||||
for (const envelope of request?.compositeQuery?.queries ?? []) {
|
||||
const spec = envelope.spec as
|
||||
| { bucketOptions?: Querybuildertypesv5BucketOptionsDTO }
|
||||
| undefined;
|
||||
if (spec?.bucketOptions) {
|
||||
return spec.bucketOptions.kind;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column width in seconds. The server's effective step is authoritative — the
|
||||
* last column has no successor to infer a width from — so the timestamp delta is
|
||||
* only a fallback for a response that reports no step.
|
||||
*/
|
||||
export function resolveHeatmapStep({
|
||||
series,
|
||||
stepInterval,
|
||||
}: {
|
||||
series: HeatmapSeries[];
|
||||
/** `meta.stepIntervals[queryName]`, in seconds. */
|
||||
stepInterval: number | undefined;
|
||||
}): number {
|
||||
if (stepInterval && stepInterval > 0) {
|
||||
return stepInterval;
|
||||
}
|
||||
|
||||
const points = series[0]?.points ?? [];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const delta = points[index].timestamp - points[index - 1].timestamp;
|
||||
if (delta > 0) {
|
||||
return delta;
|
||||
}
|
||||
}
|
||||
|
||||
return FALLBACK_STEP_SECONDS;
|
||||
}
|
||||
|
||||
function toHeatmapSeries(
|
||||
series: Querybuildertypesv5TimeSeriesDTO,
|
||||
queryName: string,
|
||||
legend: string,
|
||||
): HeatmapSeries {
|
||||
const labels = (series.labels ?? []).flatMap<HeatmapSeriesLabel>((label) =>
|
||||
label.key?.name ? [{ key: label.key.name, value: String(label.value) }] : [],
|
||||
);
|
||||
|
||||
return {
|
||||
label: resolveGroupLabel(labels, queryName, legend),
|
||||
points: (series.values ?? []).map<HeatmapSeriesPoint>((point) => ({
|
||||
timestamp: (point.timestamp ?? 0) / MS_PER_SECOND,
|
||||
// `null` is "no data"; `0` is an observed absence and stays a number.
|
||||
counts: (point.values ?? []).map((count) =>
|
||||
Number.isFinite(count) ? count : null,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGroupLabel(
|
||||
labels: HeatmapSeriesLabel[],
|
||||
queryName: string,
|
||||
legend: string,
|
||||
): string {
|
||||
const record: Record<string, string> = {};
|
||||
labels.forEach((label) => {
|
||||
record[label.key] = label.value;
|
||||
});
|
||||
return getLabelName(record, queryName, legend);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// No thresholds: colour already means "count". No bucket controls either — the
|
||||
// bucket axis comes back with the response and the panel never re-bins it.
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
// Ahead of the rest: the colour ramp is what a heatmap is read through.
|
||||
{ kind: SectionKind.ChartAppearance, controls: { colors: true } },
|
||||
// Formats the bucket bounds; counts are plain integers and take no unit.
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { yScale: true } },
|
||||
// Position only: a group's swatch colour comes off the grid's own ramp.
|
||||
{ kind: SectionKind.Legend, controls: { position: true } },
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { definition as BarChart } from './kinds/BarChartPanel/definition';
|
||||
import { definition as Heatmap } from './kinds/HeatmapPanel/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';
|
||||
@@ -23,6 +24,7 @@ export const PANELS: PanelRegistry = {
|
||||
[BarChart.kind]: BarChart,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[Heatmap.kind]: Heatmap,
|
||||
[List.kind]: List,
|
||||
[Text.kind]: Text,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
// Drag-select only: the drill-down payload has no shape for a bucket range.
|
||||
'signoz/HeatmapPanel': { onDragSelect?: DragSelect };
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
|
||||
@@ -13,8 +13,9 @@ export interface PanelQueryCapabilities {
|
||||
/** Server transposes the scalar result into UI table rows (`formatOptions.formatTableResultForUI`). */
|
||||
formatTableResultForUI: boolean;
|
||||
/**
|
||||
* Widen the step interval to cap how many buckets come back — kinds that bin
|
||||
* client-side from a raw time series rather than plotting every point.
|
||||
* Widen the step interval to cap how many points a range comes back as — kinds that
|
||||
* draw one mark per point (a bar, a column of cells) rather than a line through it,
|
||||
* where a raw-resolution series is both unreadable and more than the canvas can carry.
|
||||
*/
|
||||
bucketedStepInterval: boolean;
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/TablePanel': PANEL_TYPES.TABLE,
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
'signoz/HeatmapPanel': PANEL_TYPES.HEATMAP,
|
||||
'signoz/TextPanel': PANEL_TYPES.TEXT,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesHeatmapAxesDTO,
|
||||
DashboardtypesHeatmapChartAppearanceDTO,
|
||||
DashboardtypesHeaderOptionsDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
@@ -87,11 +89,20 @@ export type AnyThreshold =
|
||||
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
|
||||
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
|
||||
|
||||
// Superset of every kind's axes DTO, gated by the `controls` bag.
|
||||
export type PanelAxesSlice = DashboardtypesAxesDTO &
|
||||
DashboardtypesHeatmapAxesDTO;
|
||||
|
||||
// Superset of every kind's chart-appearance DTO, gated by `controls`.
|
||||
export type PanelChartAppearanceSlice =
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO &
|
||||
DashboardtypesHeatmapChartAppearanceDTO;
|
||||
|
||||
export interface SectionSpecMap {
|
||||
[SectionKind.Formatting]: PanelFormattingSlice; // spec.plugin.spec.formatting
|
||||
[SectionKind.Axes]: DashboardtypesAxesDTO; // spec.plugin.spec.axes
|
||||
[SectionKind.Axes]: PanelAxesSlice; // spec.plugin.spec.axes
|
||||
[SectionKind.Legend]: DashboardtypesLegendDTO; // spec.plugin.spec.legend
|
||||
[SectionKind.ChartAppearance]: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
|
||||
[SectionKind.ChartAppearance]: PanelChartAppearanceSlice; // spec.plugin.spec.chartAppearance
|
||||
[SectionKind.Buckets]: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
|
||||
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
|
||||
// the `controls` bag gates which fields each kind writes.
|
||||
@@ -113,7 +124,13 @@ export interface SectionControls {
|
||||
decimals?: boolean;
|
||||
columnUnits?: boolean;
|
||||
};
|
||||
[SectionKind.Axes]: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
|
||||
// minMax → softMin/softMax; yScale → the heatmap's bucket-axis distribution,
|
||||
// whose domain comes from the response and so takes no soft bounds.
|
||||
[SectionKind.Axes]: {
|
||||
minMax?: boolean;
|
||||
logScale?: boolean;
|
||||
yScale?: boolean;
|
||||
};
|
||||
[SectionKind.Legend]: {
|
||||
position?: boolean;
|
||||
// colors → customColors; the resolver supplies the labels overrides are keyed by,
|
||||
@@ -126,6 +143,8 @@ export interface SectionControls {
|
||||
fillMode?: boolean;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: boolean;
|
||||
// colors → the heatmap's cell colour ramp; nothing else here applies to a grid.
|
||||
colors?: boolean;
|
||||
};
|
||||
[SectionKind.Buckets]: {
|
||||
count?: boolean;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
DashboardtypesHeatmapYScaleDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
@@ -170,10 +174,7 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
[SectionKind.Axes]: {
|
||||
specKey: 'axes',
|
||||
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Axes] => {
|
||||
const old = oldPluginSpec?.axes;
|
||||
if (!old) {
|
||||
return {};
|
||||
}
|
||||
const old = oldPluginSpec?.axes ?? {};
|
||||
return {
|
||||
...(controls.minMax &&
|
||||
typeof old.softMin === 'number' && { softMin: old.softMin }),
|
||||
@@ -181,6 +182,10 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
typeof old.softMax === 'number' && { softMax: old.softMax }),
|
||||
...(controls.logScale &&
|
||||
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
|
||||
// Seeded so the control opens on the axis being drawn.
|
||||
...(controls.yScale && {
|
||||
yScale: old.yScale ?? DashboardtypesHeatmapYScaleDTO.auto,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -207,6 +212,7 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
fillMode = DashboardtypesFillModeDTO.none,
|
||||
showPoints,
|
||||
spanGaps,
|
||||
colors,
|
||||
} = oldPluginSpec?.chartAppearance ?? {};
|
||||
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
|
||||
if (controls.lineStyle) {
|
||||
@@ -224,6 +230,16 @@ const SECTION_SEEDS: SectionSeeds = {
|
||||
if (controls.spanGaps && spanGaps !== undefined) {
|
||||
appearance.spanGaps = spanGaps;
|
||||
}
|
||||
// Seeded so the controls open on the ramp the grid is drawn with; the
|
||||
// count bounds and step count stay unset, which reads as derived.
|
||||
if (controls.colors) {
|
||||
appearance.colors = {
|
||||
...colors,
|
||||
mode: colors?.mode ?? DashboardtypesHeatmapColorModeDTO.palette,
|
||||
palette: colors?.palette ?? DashboardtypesHeatmapPaletteDTO.lava,
|
||||
scale: colors?.scale ?? DashboardtypesHeatmapColorScaleDTO.log,
|
||||
};
|
||||
}
|
||||
return appearance;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
import { resolveSpanGaps } from '../resolvers';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
DashboardtypesHeatmapYScaleDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapColorMode,
|
||||
HeatmapColorPalette,
|
||||
HeatmapColorScale,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import {
|
||||
resolveHeatmapAxisScale,
|
||||
resolveHeatmapColors,
|
||||
resolveSpanGaps,
|
||||
} from '../resolvers';
|
||||
|
||||
describe('resolveSpanGaps', () => {
|
||||
it('parses a duration string into seconds when thresholding', () => {
|
||||
@@ -33,3 +52,96 @@ describe('resolveSpanGaps', () => {
|
||||
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapAxisScale', () => {
|
||||
it('maps each scale onto its own axis — log and symlog are not the same', () => {
|
||||
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.auto)).toBe(
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.linear)).toBe(
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.log)).toBe(
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.symlog)).toBe(
|
||||
HeatmapAxisScale.Symlog,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an unset scale to the bucket bounds', () => {
|
||||
expect(resolveHeatmapAxisScale(undefined)).toBe(HeatmapAxisScale.Auto);
|
||||
});
|
||||
|
||||
it('follows linear bucketing when the panel has not chosen a scale', () => {
|
||||
expect(
|
||||
resolveHeatmapAxisScale(
|
||||
undefined,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
),
|
||||
).toBe(HeatmapAxisScale.Linear);
|
||||
expect(
|
||||
resolveHeatmapAxisScale(
|
||||
DashboardtypesHeatmapYScaleDTO.auto,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
),
|
||||
).toBe(HeatmapAxisScale.Linear);
|
||||
});
|
||||
|
||||
it('leaves log bucketing to the bucket bounds', () => {
|
||||
expect(
|
||||
resolveHeatmapAxisScale(
|
||||
undefined,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
),
|
||||
).toBe(HeatmapAxisScale.Auto);
|
||||
});
|
||||
|
||||
it('lets an explicit scale outrank the query bucketing', () => {
|
||||
expect(
|
||||
resolveHeatmapAxisScale(
|
||||
DashboardtypesHeatmapYScaleDTO.log,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
),
|
||||
).toBe(HeatmapAxisScale.Log);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapColors', () => {
|
||||
it('maps each wire enum onto its chart counterpart', () => {
|
||||
expect(
|
||||
resolveHeatmapColors({
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
palette: DashboardtypesHeatmapPaletteDTO.verdant,
|
||||
fill: '#ff0000',
|
||||
}),
|
||||
).toStrictEqual({
|
||||
mode: HeatmapColorMode.Opacity,
|
||||
scale: HeatmapColorScale.Sqrt,
|
||||
palette: HeatmapColorPalette.Verdant,
|
||||
fill: '#ff0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits every unset field, so the chart keeps its own defaults', () => {
|
||||
expect(resolveHeatmapColors(undefined)).toStrictEqual({});
|
||||
expect(resolveHeatmapColors({})).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("keeps a null count bound — the spec's way of asking for a derived one", () => {
|
||||
expect(resolveHeatmapColors({ minCount: null, maxCount: 500 })).toStrictEqual(
|
||||
{ minCount: null, maxCount: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps a step count the chart could not draw', () => {
|
||||
expect(resolveHeatmapColors({ steps: 1 }).steps).toBe(2);
|
||||
expect(resolveHeatmapColors({ steps: 500 }).steps).toBe(128);
|
||||
expect(resolveHeatmapColors({ steps: 32 }).steps).toBe(32);
|
||||
});
|
||||
|
||||
it('reads a zero step count as unset, which is how the wire sends none', () => {
|
||||
expect(resolveHeatmapColors({ steps: 0 })).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
DashboardtypesHeatmapYScaleDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
@@ -10,6 +14,12 @@ import {
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapColorMode,
|
||||
HeatmapColorPalette,
|
||||
HeatmapColorScale,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
/**
|
||||
* Bridges the V2 wire-format enums to the uPlotV2 chart enums. String values
|
||||
@@ -45,3 +55,46 @@ export const LEGEND_POSITION_MAP: Record<
|
||||
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
|
||||
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
|
||||
};
|
||||
|
||||
export const HEATMAP_COLOR_MODE_MAP: Record<
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
HeatmapColorMode
|
||||
> = {
|
||||
[DashboardtypesHeatmapColorModeDTO.palette]: HeatmapColorMode.Palette,
|
||||
[DashboardtypesHeatmapColorModeDTO.opacity]: HeatmapColorMode.Opacity,
|
||||
};
|
||||
|
||||
export const HEATMAP_COLOR_SCALE_MAP: Record<
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
HeatmapColorScale
|
||||
> = {
|
||||
[DashboardtypesHeatmapColorScaleDTO.log]: HeatmapColorScale.Log,
|
||||
[DashboardtypesHeatmapColorScaleDTO.sqrt]: HeatmapColorScale.Sqrt,
|
||||
[DashboardtypesHeatmapColorScaleDTO.linear]: HeatmapColorScale.Linear,
|
||||
};
|
||||
|
||||
export const HEATMAP_PALETTE_MAP: Record<
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
HeatmapColorPalette
|
||||
> = {
|
||||
[DashboardtypesHeatmapPaletteDTO.ice]: HeatmapColorPalette.Ice,
|
||||
[DashboardtypesHeatmapPaletteDTO.moss]: HeatmapColorPalette.Moss,
|
||||
[DashboardtypesHeatmapPaletteDTO.rust]: HeatmapColorPalette.Rust,
|
||||
[DashboardtypesHeatmapPaletteDTO.graphite]: HeatmapColorPalette.Graphite,
|
||||
[DashboardtypesHeatmapPaletteDTO.ember]: HeatmapColorPalette.Ember,
|
||||
[DashboardtypesHeatmapPaletteDTO.lagoon]: HeatmapColorPalette.Lagoon,
|
||||
[DashboardtypesHeatmapPaletteDTO.orchid]: HeatmapColorPalette.Orchid,
|
||||
[DashboardtypesHeatmapPaletteDTO.verdant]: HeatmapColorPalette.Verdant,
|
||||
[DashboardtypesHeatmapPaletteDTO.lava]: HeatmapColorPalette.Lava,
|
||||
[DashboardtypesHeatmapPaletteDTO.beacon]: HeatmapColorPalette.Beacon,
|
||||
};
|
||||
|
||||
export const HEATMAP_Y_SCALE_MAP: Record<
|
||||
DashboardtypesHeatmapYScaleDTO,
|
||||
HeatmapAxisScale
|
||||
> = {
|
||||
[DashboardtypesHeatmapYScaleDTO.auto]: HeatmapAxisScale.Auto,
|
||||
[DashboardtypesHeatmapYScaleDTO.linear]: HeatmapAxisScale.Linear,
|
||||
[DashboardtypesHeatmapYScaleDTO.log]: HeatmapAxisScale.Log,
|
||||
[DashboardtypesHeatmapYScaleDTO.symlog]: HeatmapAxisScale.Symlog,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import {
|
||||
DashboardtypesHeatmapYScaleDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
type DashboardtypesHeatmapColorsDTO,
|
||||
type DashboardtypesSpanGapsDTO,
|
||||
type Querybuildertypesv5BucketOptionsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { clampColorSteps } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
type HeatmapColorOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { LEGEND_POSITION_MAP } from './enumMaps';
|
||||
import {
|
||||
HEATMAP_COLOR_MODE_MAP,
|
||||
HEATMAP_COLOR_SCALE_MAP,
|
||||
HEATMAP_PALETTE_MAP,
|
||||
HEATMAP_Y_SCALE_MAP,
|
||||
LEGEND_POSITION_MAP,
|
||||
} from './enumMaps';
|
||||
|
||||
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
|
||||
// values, falling back to chart defaults for missing/unknown input.
|
||||
@@ -65,3 +80,49 @@ export function resolveLegendPosition(
|
||||
}
|
||||
return LegendPosition.BOTTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-height distribution of a heatmap's bucket axis. Missing/unknown resolves to
|
||||
* `auto`, the one option that reads the bucket bounds rather than overriding them.
|
||||
* Under `auto`, linear bucketing gets a linear row layout: its bounds are evenly
|
||||
* spaced, and a log one would squeeze the axis into the bottom of the panel.
|
||||
*/
|
||||
export function resolveHeatmapAxisScale(
|
||||
yScale: DashboardtypesHeatmapYScaleDTO | undefined,
|
||||
bucketKind?: Querybuildertypesv5BucketOptionsDTO['kind'],
|
||||
): HeatmapAxisScale {
|
||||
const configured =
|
||||
yScale && yScale in HEATMAP_Y_SCALE_MAP
|
||||
? HEATMAP_Y_SCALE_MAP[yScale]
|
||||
: HeatmapAxisScale.Auto;
|
||||
|
||||
if (configured !== HeatmapAxisScale.Auto) {
|
||||
return configured;
|
||||
}
|
||||
return bucketKind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear
|
||||
? HeatmapAxisScale.Linear
|
||||
: HeatmapAxisScale.Auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* `chartAppearance.colors` → the chart's colour options. Only fields the spec sets
|
||||
* are returned: the chart spreads this over its own defaults, so an explicit
|
||||
* `undefined` would erase one. `null` bounds pass through — that is the spec's own
|
||||
* way of asking for a derived one.
|
||||
*/
|
||||
export function resolveHeatmapColors(
|
||||
colors: DashboardtypesHeatmapColorsDTO | undefined,
|
||||
): Partial<HeatmapColorOptions> {
|
||||
if (!colors) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(colors.mode && { mode: HEATMAP_COLOR_MODE_MAP[colors.mode] }),
|
||||
...(colors.scale && { scale: HEATMAP_COLOR_SCALE_MAP[colors.scale] }),
|
||||
...(colors.palette && { palette: HEATMAP_PALETTE_MAP[colors.palette] }),
|
||||
...(colors.steps && { steps: clampColorSteps(colors.steps) }),
|
||||
...(colors.minCount !== undefined && { minCount: colors.minCount }),
|
||||
...(colors.maxCount !== undefined && { maxCount: colors.maxCount }),
|
||||
...(colors.fill !== undefined && { fill: colors.fill }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import {
|
||||
buildQueryRangeRequest,
|
||||
extractLegendMap,
|
||||
getBarStepIntervalSeconds,
|
||||
getBucketedStepIntervalSeconds,
|
||||
hasRunnableQueries,
|
||||
toQueryEnvelopes,
|
||||
} from '../buildQueryRangeRequest';
|
||||
@@ -51,10 +51,15 @@ const TIME_SERIES_CAPABILITIES = {
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
};
|
||||
const BAR_CAPABILITIES = {
|
||||
const BUCKETED_CAPABILITIES = {
|
||||
...TIME_SERIES_CAPABILITIES,
|
||||
bucketedStepInterval: true,
|
||||
};
|
||||
const HEATMAP_CAPABILITIES = {
|
||||
...TIME_SERIES_CAPABILITIES,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.heatmap,
|
||||
bucketedStepInterval: true,
|
||||
};
|
||||
const TABLE_CAPABILITIES = {
|
||||
...TIME_SERIES_CAPABILITIES,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
@@ -289,10 +294,10 @@ describe('buildQueryRangeRequest', () => {
|
||||
expect(spec.order).toStrictEqual(order);
|
||||
});
|
||||
|
||||
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
|
||||
it('injects the range-derived stepInterval into builder queries without one', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
|
||||
queryCapabilities: BAR_CAPABILITIES,
|
||||
queryCapabilities: BUCKETED_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -300,14 +305,33 @@ describe('buildQueryRangeRequest', () => {
|
||||
stepInterval?: number;
|
||||
};
|
||||
expect(spec.stepInterval).toBe(
|
||||
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
|
||||
getBucketedStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a user-set stepInterval on BAR builder queries', () => {
|
||||
// The bucket axis rides on the query it draws, so it reaches the request the same
|
||||
// way every other query field does — nothing at request level carries it.
|
||||
it("carries a query's bucket options into the payload", () => {
|
||||
const bucketOptions = { kind: 'log', spec: { scale: 0 } };
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'metrics', bucketOptions }),
|
||||
queryCapabilities: HEATMAP_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
|
||||
expect(request.requestType).toBe(Querybuildertypesv5RequestTypeDTO.heatmap);
|
||||
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
|
||||
bucketOptions?: unknown;
|
||||
};
|
||||
expect(spec.bucketOptions).toStrictEqual(bucketOptions);
|
||||
expect(request).not.toHaveProperty('bucketOptions');
|
||||
});
|
||||
|
||||
it('preserves a user-set stepInterval', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
|
||||
queryCapabilities: BAR_CAPABILITIES,
|
||||
queryCapabilities: BUCKETED_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
@@ -317,7 +341,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
expect(spec.stepInterval).toBe(300);
|
||||
});
|
||||
|
||||
it('does not touch stepInterval for non-BAR panels', () => {
|
||||
it('does not touch stepInterval for kinds that plot every point', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A' }),
|
||||
queryCapabilities: TIME_SERIES_CAPABILITIES,
|
||||
@@ -331,7 +355,7 @@ describe('buildQueryRangeRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBarStepIntervalSeconds', () => {
|
||||
describe('getBucketedStepIntervalSeconds', () => {
|
||||
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
|
||||
it.each([
|
||||
[30, 60],
|
||||
@@ -340,12 +364,12 @@ describe('getBarStepIntervalSeconds', () => {
|
||||
[180, 120],
|
||||
[300, 180],
|
||||
])('%s min range → %s s step', (minutes, step) => {
|
||||
expect(getBarStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
|
||||
expect(getBucketedStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
|
||||
});
|
||||
|
||||
it('caps long ranges at ~80 bars, rounded to 5-minute steps', () => {
|
||||
it('caps long ranges at ~80 points, rounded to 5-minute steps', () => {
|
||||
// 24h = 1440 min → 1440/80 = 18 → rounded up to 20 min → 1200 s
|
||||
expect(getBarStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
|
||||
expect(getBucketedStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -103,6 +105,102 @@ describe('persesQueryAdapters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucket options', () => {
|
||||
const bucketOptions = {
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale: 0 },
|
||||
};
|
||||
|
||||
const heatmapQuery = (): Query => ({
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
bucketOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it('writes the axis onto the builder query spec', () => {
|
||||
const result = toPerses(heatmapQuery(), PANEL_TYPES.HEATMAP);
|
||||
|
||||
expect(result[0].kind).toBe('heatmap');
|
||||
const [envelope] = (
|
||||
result[0].spec.plugin.spec as Querybuildertypesv5CompositeQueryDTO
|
||||
).queries as [Querybuildertypesv5QueryEnvelopeDTO];
|
||||
expect(
|
||||
(envelope.spec as { bucketOptions?: unknown }).bucketOptions,
|
||||
).toStrictEqual(bucketOptions);
|
||||
});
|
||||
|
||||
it('reads the axis back off the builder query spec', () => {
|
||||
const restored = fromPerses(
|
||||
toPerses(heatmapQuery(), PANEL_TYPES.HEATMAP),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(restored.builder.queryData[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
});
|
||||
|
||||
// Explicit-undefined, like every other unset field `createBaseSpec` emits; it
|
||||
// drops out of the payload at JSON.stringify.
|
||||
it("writes and reads a formula's own axis", () => {
|
||||
const base = initialQueriesMap[DataSource.METRICS];
|
||||
const withFormula: Query = {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], disabled: true }],
|
||||
queryFormulas: [
|
||||
{
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
bucketOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const perses = toPerses(withFormula, PANEL_TYPES.HEATMAP);
|
||||
const envelopes = (
|
||||
perses[0].spec.plugin.spec as Querybuildertypesv5CompositeQueryDTO
|
||||
).queries as Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
const formulaEnvelope = envelopes.find(
|
||||
(envelope) => envelope.type === 'builder_formula',
|
||||
);
|
||||
|
||||
expect(
|
||||
(formulaEnvelope?.spec as { bucketOptions?: unknown })?.bucketOptions,
|
||||
).toStrictEqual(bucketOptions);
|
||||
|
||||
const restored = fromPerses(perses, PANEL_TYPES.HEATMAP);
|
||||
expect(restored.builder.queryFormulas[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits no axis when none is set', () => {
|
||||
const result = toPerses(
|
||||
initialQueriesMap[DataSource.METRICS],
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
const [envelope] = (
|
||||
result[0].spec.plugin.spec as Querybuildertypesv5CompositeQueryDTO
|
||||
).queries as [Querybuildertypesv5QueryEnvelopeDTO];
|
||||
expect(
|
||||
(envelope.spec as { bucketOptions?: unknown }).bucketOptions,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPerses', () => {
|
||||
it('wraps the query in a single signoz/CompositeQuery keyed to the panel request type', () => {
|
||||
const result = toPerses(
|
||||
|
||||
@@ -88,10 +88,11 @@ export function toQueryEnvelopes(
|
||||
}
|
||||
|
||||
/**
|
||||
* Step interval (seconds) for BAR panels capping the bar count (~80 max). Duplicated from V1
|
||||
* `getBarStepIntervalPoints` per the V1/V2 split policy.
|
||||
* Step interval (seconds) capping how many points a range comes back as (~80 max), for the
|
||||
* kinds that draw one mark per point. Duplicated from V1 `getBarStepIntervalPoints` per the
|
||||
* V1/V2 split policy.
|
||||
*/
|
||||
export function getBarStepIntervalSeconds(
|
||||
export function getBucketedStepIntervalSeconds(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): number {
|
||||
@@ -115,15 +116,16 @@ export function getBarStepIntervalSeconds(
|
||||
}
|
||||
|
||||
/**
|
||||
* BAR panels: builder queries without a user-set stepInterval get the range-derived one so bars
|
||||
* align (V1 parity: `updateBarStepInterval`).
|
||||
* Builder queries without a user-set stepInterval get the range-derived one, so a wide range
|
||||
* comes back as a readable number of marks instead of one per raw point (V1 parity:
|
||||
* `updateBarStepInterval`). An explicit interval is the user's call and is left alone.
|
||||
*/
|
||||
function withBarStepInterval(
|
||||
function withBucketedStepInterval(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
|
||||
const stepInterval = getBucketedStepIntervalSeconds(startMs, endMs);
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
@@ -253,7 +255,7 @@ export function buildQueryRangeRequest({
|
||||
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
|
||||
let envelopes = toQueryEnvelopes(queries);
|
||||
if (bucketedStepInterval) {
|
||||
envelopes = withBarStepInterval(envelopes, startMs, endMs);
|
||||
envelopes = withBucketedStepInterval(envelopes, startMs, endMs);
|
||||
}
|
||||
if (orderTiebreaker) {
|
||||
envelopes = withListOrderTiebreaker(envelopes);
|
||||
|
||||
@@ -108,6 +108,8 @@ export function panelTypeToRequestType(
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
case PANEL_TYPES.HEATMAP:
|
||||
return Querybuildertypesv5RequestTypeDTO.heatmap;
|
||||
case PANEL_TYPES.TRACE:
|
||||
return Querybuildertypesv5RequestTypeDTO.trace;
|
||||
default:
|
||||
|
||||
@@ -25,6 +25,17 @@ export function getTimeSeriesResults(
|
||||
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
|
||||
}
|
||||
|
||||
/** The time-series tree with bucket bounds on each aggregation's `meta`. */
|
||||
export function getHeatmapResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5TimeSeriesDataDTO[] {
|
||||
if (response?.data?.type !== 'heatmap') {
|
||||
return [];
|
||||
}
|
||||
return (response.data.data?.results ??
|
||||
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
|
||||
}
|
||||
|
||||
export function getScalarResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5ScalarDataDTO[] {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { Format } from 'constants/formats/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -28,6 +29,8 @@ export interface IBuilderFormula {
|
||||
having?: Having[];
|
||||
stepInterval?: number;
|
||||
orderBy?: OrderByPayload[];
|
||||
/** Heatmap only, like `IBuilderQuery['bucketOptions']`. */
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
}
|
||||
|
||||
export type IBuilderTraceOperator = IBuilderQuery;
|
||||
@@ -92,6 +95,12 @@ export type IBuilderQuery = {
|
||||
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
|
||||
source?: 'meter' | '';
|
||||
builderQueryType?: BuilderQueryType;
|
||||
/**
|
||||
* Heatmap only, and request-level on the wire: the bucket axis is shared by every
|
||||
* query in the request, and a heatmap admits exactly one enabled query, so the
|
||||
* builder edits it alongside that query.
|
||||
*/
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
};
|
||||
|
||||
export interface IClickHouseQuery {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ===================== Base Types =====================
|
||||
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { Warning } from '..';
|
||||
@@ -230,6 +231,11 @@ export interface BaseBuilderQuery {
|
||||
functions?: QueryFunction[];
|
||||
legend?: string;
|
||||
expression?: string; // for trace operator
|
||||
/**
|
||||
* The bucket axis to count this query's values into. Only a heatmap request reads
|
||||
* it, and only from the one query it draws.
|
||||
*/
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
}
|
||||
|
||||
export interface TraceBuilderQuery extends BaseBuilderQuery {
|
||||
@@ -267,6 +273,12 @@ export interface QueryBuilderFormula {
|
||||
limit?: number;
|
||||
having?: Having;
|
||||
legend?: string;
|
||||
/**
|
||||
* The bucket axis to count the formula's results into. Only a heatmap request reads
|
||||
* it, and only from the one query it draws — which is the formula when its inputs are
|
||||
* disabled.
|
||||
*/
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
}
|
||||
|
||||
export interface QueryBuilderJoin {
|
||||
|
||||
@@ -25,6 +25,9 @@ function convertFormulasToV5(
|
||||
disabled: formulaData.disabled,
|
||||
limit: formulaData.limit ?? undefined,
|
||||
legend: formulaData.legend,
|
||||
// Heatmap only; `handleQueryChange` is what keeps one off a formula whose
|
||||
// panel type switched away.
|
||||
bucketOptions: formulaData.bucketOptions,
|
||||
order: formulaData.orderBy?.map(
|
||||
(order: any): OrderBy => ({
|
||||
key: {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
|
||||
[PANEL_TYPES.BAR]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HEATMAP]: ExplorerViews.TIMESERIES,
|
||||
// Dashboard-only visualisation; explorers never offer it.
|
||||
[PANEL_TYPES.TEXT]: ExplorerViews.LIST,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: ExplorerViews.LIST,
|
||||
|
||||
Reference in New Issue
Block a user