mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-06 03:20:41 +01:00
Compare commits
9 Commits
feat/heatm
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61f668ca30 | ||
|
|
48b12c6247 | ||
|
|
a97e9838ad | ||
|
|
368b2a0648 | ||
|
|
46e6132297 | ||
|
|
f54a33c3ee | ||
|
|
c170707757 | ||
|
|
f6c34795a5 | ||
|
|
37ef1cd1db |
@@ -2,8 +2,6 @@
|
||||
|
||||
// Mock for uplot library used in tests
|
||||
export interface MockUPlotInstance {
|
||||
/** Consumers read `root.parentElement` to detect a re-mounted container. */
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
@@ -19,20 +17,13 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
|
||||
const root = document.createElement('div');
|
||||
// Real uPlot mounts its root inside the target; without it a re-render reads
|
||||
// `root.parentElement` off undefined and throws.
|
||||
target?.appendChild(root);
|
||||
return {
|
||||
root,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
};
|
||||
};
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -62,16 +53,14 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
target: HTMLElement,
|
||||
_target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance(target);
|
||||
return createMockUPlotInstance();
|
||||
}
|
||||
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 12px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
background: var(--text-vanilla-100);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.swatch,
|
||||
.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
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Styles from './ColorBar.module.scss';
|
||||
|
||||
export interface ColorBarProps {
|
||||
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
|
||||
* colours as the cells. */
|
||||
ramp: string[];
|
||||
minLabel: string;
|
||||
maxLabel: string;
|
||||
/** 0..1. `null` hides the marker. */
|
||||
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;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** What a colour means, plus a marker for the value under the cursor. */
|
||||
export default function ColorBar({
|
||||
ramp,
|
||||
minLabel,
|
||||
maxLabel,
|
||||
markerPosition = null,
|
||||
label,
|
||||
showStateKeys = true,
|
||||
'data-testid': testId = 'color-bar',
|
||||
}: ColorBarProps): JSX.Element | null {
|
||||
const gradient = useMemo(() => {
|
||||
if (ramp.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => {
|
||||
const from = (index / ramp.length) * 100;
|
||||
const to = ((index + 1) / ramp.length) * 100;
|
||||
return [`${color} ${from}%`, `${color} ${to}%`];
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [ramp]);
|
||||
|
||||
if (gradient === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedMarker =
|
||||
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
|
||||
|
||||
return (
|
||||
<div className={Styles.container} data-testid={testId}>
|
||||
{label && <span className={Styles.caption}>{label}</span>}
|
||||
<span className={Styles.label}>{minLabel}</span>
|
||||
<div className={Styles.track} style={{ background: gradient }}>
|
||||
{clampedMarker !== null && (
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ left: `${clampedMarker * 100}%` }}
|
||||
data-testid={`${testId}-marker`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={Styles.label}>{maxLabel}</span>
|
||||
{showStateKeys && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ColorBar from '../ColorBar';
|
||||
|
||||
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
|
||||
|
||||
describe('ColorBar', () => {
|
||||
it('renders the domain labels', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing without a ramp', () => {
|
||||
const { container } = render(
|
||||
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('hides the marker when nothing is hovered', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions the marker at the hovered value', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
|
||||
});
|
||||
|
||||
it('clamps a marker outside the ramp to its ends', () => {
|
||||
const { rerender } = render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
|
||||
|
||||
rerender(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
|
||||
);
|
||||
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" />);
|
||||
|
||||
expect(screen.getByText('no data')).toBeInTheDocument();
|
||||
expect(screen.getByText('count 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the count-0 key with the bottom of the ramp', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('captions what the colour encodes', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
|
||||
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hard-edged segments so the bar matches the drawn cells', () => {
|
||||
render(
|
||||
<ColorBar
|
||||
ramp={['#111111', '#dddddd']}
|
||||
minLabel="0"
|
||||
maxLabel="10"
|
||||
data-testid="scale"
|
||||
/>,
|
||||
);
|
||||
|
||||
const track = screen.getByTestId('scale').querySelector('div');
|
||||
expect(track).toHaveStyle({
|
||||
background:
|
||||
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** The buckets either side of the hovered one, so a mode reads as a shape rather
|
||||
* than a single number. */
|
||||
export default function HeatmapBucketList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapBucketRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: row.isHovered })}
|
||||
data-hovered={row.isHovered}
|
||||
data-testid="heatmap-tooltip-bucket-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import {
|
||||
formatCount,
|
||||
formatPercent,
|
||||
HeatmapContributionRow,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
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>}
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="heatmap-tooltip-contribution-row"
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ background: row.color }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing).
|
||||
//
|
||||
// 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 {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
// Separates the cell identity from whichever question the second block answers.
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Hollow ring, matching the legend's unselected marker — this names the filter the
|
||||
// grid is under, it is not a colour key.
|
||||
.filterMarker {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.titleBucket {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.titleCount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.section {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
padding: 0 var(--spacing-2) var(--spacing-1);
|
||||
}
|
||||
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowPercent {
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: var(--text-vanilla-400);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
resolveColumnIndex,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { HeatmapTooltipProps } from '../../../types';
|
||||
import HeatmapBucketList from './HeatmapBucketList';
|
||||
import HeatmapContributionList from './HeatmapContributionList';
|
||||
import {
|
||||
buildBucketRows,
|
||||
buildContributionRows,
|
||||
formatBucketLabel,
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
formatGroupFilter,
|
||||
HeatmapTooltipBody,
|
||||
resolveGroupByLabel,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The cell identity is the same in every state; the second block answers whichever
|
||||
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, which renders a flat list of
|
||||
* series values — none of these states is that shape.
|
||||
*
|
||||
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
|
||||
* the nearest timestamp, so half of every column would report its neighbour.
|
||||
*/
|
||||
export default function HeatmapTooltip({
|
||||
uPlotInstance,
|
||||
yAxis,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
groupColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: HeatmapTooltipProps): JSX.Element | null {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
// Read outside the memo: uPlot mutates the same instance on every move, so
|
||||
// keying off the instance alone would freeze the cell.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const cell = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
|
||||
const column = resolveColumnIndex(
|
||||
timestamps,
|
||||
uPlotInstance.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
row,
|
||||
column,
|
||||
timestamp: timestamps[column],
|
||||
count:
|
||||
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
] ?? null,
|
||||
};
|
||||
}, [left, top, uPlotInstance, yAxis, step]);
|
||||
|
||||
// The cell sums the enabled groups, so those are what a breakdown must cover.
|
||||
const visible = useMemo(
|
||||
() => series.filter((entry) => visibleGroups.includes(entry.label)),
|
||||
[series, visibleGroups],
|
||||
);
|
||||
const body = resolveTooltipBody(visible.length);
|
||||
|
||||
const bucketRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Buckets) {
|
||||
return [];
|
||||
}
|
||||
return buildBucketRows({
|
||||
counts: uPlotInstance.data.slice(1) as Array<
|
||||
ArrayLike<number | null> | undefined
|
||||
>,
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
column: cell.column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
|
||||
|
||||
const contributionRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Contribution) {
|
||||
return [];
|
||||
}
|
||||
return buildContributionRows({
|
||||
series: visible,
|
||||
timestamp: cell.timestamp,
|
||||
row: cell.row,
|
||||
color: groupColor,
|
||||
});
|
||||
}, [cell, body, visible, groupColor]);
|
||||
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="heatmap-tooltip"
|
||||
>
|
||||
<div className={Styles.identity}>
|
||||
<div className={Styles.header}>
|
||||
<span data-testid="heatmap-tooltip-range">
|
||||
{formatColumnRange({
|
||||
start: cell.timestamp,
|
||||
step,
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={Styles.title}>
|
||||
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
|
||||
{formatBucketLabel({
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
})}
|
||||
</span>
|
||||
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
|
||||
{formatCount(cell.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
|
||||
|
||||
{body === HeatmapTooltipBody.Contribution ? (
|
||||
<HeatmapContributionList
|
||||
rows={contributionRows}
|
||||
groupByLabel={resolveGroupByLabel(series)}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapBucketList rows={bucketRows} />
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import HeatmapTooltip from '../HeatmapTooltip';
|
||||
|
||||
const BOUNDS = [100, 500, 1000, 2500];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
|
||||
const STEP = 300;
|
||||
const PLOT_SIZE = 500;
|
||||
const ROW_COUNT = BOUNDS.length + 1;
|
||||
|
||||
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
|
||||
const HOVERED_ROW = 2;
|
||||
|
||||
function seriesFor(
|
||||
group: string,
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `service.name=${group}`,
|
||||
labels: [{ key: 'service.name', value: group }],
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const GROUPED: HeatmapSeries[] = [
|
||||
seriesFor('checkout', [355, 300]),
|
||||
seriesFor('frontend', [86, 80]),
|
||||
seriesFor('cart', [14, 10]),
|
||||
seriesFor('payments', [0, 0]),
|
||||
];
|
||||
|
||||
/** Grid counts, matching what the renderer would have been handed. */
|
||||
function gridData(rowTotals: number[]): uPlot.AlignedData {
|
||||
return [
|
||||
TIMESTAMPS,
|
||||
...Array.from({ length: ROW_COUNT }, (_, row) => [
|
||||
rowTotals[row] ?? row * 40,
|
||||
rowTotals[row] ?? row * 40,
|
||||
]),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
|
||||
const ROW_TOTALS = [10, 269, 455, 92, 2];
|
||||
|
||||
function createFakePlot(): uPlot {
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
// Aim the cursor at the middle of the hovered row, first column.
|
||||
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
|
||||
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
|
||||
|
||||
return {
|
||||
data: gridData(ROW_TOTALS),
|
||||
cursor: { left: PLOT_SIZE * 0.25, top },
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<HeatmapTooltip
|
||||
id="panel-1"
|
||||
uPlotInstance={createFakePlot()}
|
||||
dataIndexes={[]}
|
||||
seriesIndex={null}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
viaSync={false}
|
||||
yAxis={Y_AXIS}
|
||||
step={STEP}
|
||||
series={GROUPED}
|
||||
visibleGroups={GROUPED.map((entry) => entry.label)}
|
||||
groupColor="#fcfdbf"
|
||||
yAxisUnit="ms"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeatmapTooltip — cell identity', () => {
|
||||
it('heads with the time span the column covers, not a single instant', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the hovered bucket and its count', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
|
||||
'500 ms – 1 s',
|
||||
);
|
||||
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
|
||||
});
|
||||
|
||||
it('marks the surface as pinned so the border picks up the ring', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the cell identity from the block below it', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a footer when the panel supplies one', () => {
|
||||
renderTooltip({
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
|
||||
});
|
||||
|
||||
it('tells the footer when the tooltip is pinned', () => {
|
||||
renderTooltip({
|
||||
isPinned: true,
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
|
||||
});
|
||||
|
||||
it('renders nothing when the cursor is off the plot', () => {
|
||||
const plot = createFakePlot();
|
||||
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
|
||||
|
||||
const { container } = renderTooltip({ uPlotInstance: plot });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
it('breaks the cell down by group instead of showing neighbours', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('heatmap-tooltip-contribution'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-buckets'),
|
||||
).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', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
expect(rows[0]).toContain('checkout');
|
||||
expect(rows[0]).toContain('355');
|
||||
expect(rows[1]).toContain('frontend');
|
||||
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%');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['service.name=checkout'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the active filter', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'service.name = checkout',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — no grouping', () => {
|
||||
const ungrouped = {
|
||||
series: [{ label: '', points: GROUPED[0].points }],
|
||||
visibleGroups: [''],
|
||||
};
|
||||
|
||||
it('shows neighbouring buckets, highest first', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
// Two buckets either side of 500ms – 1s, reading down the y axis.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0]).toContain('> 2.5 s');
|
||||
expect(rows[2]).toContain('500 ms – 1 s');
|
||||
expect(rows[4]).toContain('≤ 100 ms');
|
||||
});
|
||||
|
||||
it('marks the hovered bucket among its neighbours', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const hovered = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.filter((row) => row.dataset.hovered === 'true');
|
||||
|
||||
expect(hovered).toHaveLength(1);
|
||||
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
|
||||
});
|
||||
|
||||
it('never breaks down a single series', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
/** 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;
|
||||
|
||||
export const NO_DATA_LABEL = 'no data';
|
||||
|
||||
/**
|
||||
* Which question the second block answers. A cell summed across several groups begs
|
||||
* "which group?"; a cell that is already one series begs "how does this bucket
|
||||
* compare with its neighbours?".
|
||||
*/
|
||||
export enum HeatmapTooltipBody {
|
||||
Buckets = 'buckets',
|
||||
Contribution = 'contribution',
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
label: string;
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Share of the cell's total, 0..100. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
// One enabled group contributes the whole cell, so there is nothing to break
|
||||
// down — whether the query is ungrouped or the legend has isolated a group.
|
||||
return visibleCount > 1
|
||||
? HeatmapTooltipBody.Contribution
|
||||
: 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. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
timezone,
|
||||
}: {
|
||||
/** Column start, in seconds. */
|
||||
start: number;
|
||||
/** Column width, in seconds. */
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const format =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
return `${from.format(format)} → ${to.format(format)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
export function formatBucketLabel({
|
||||
yAxis,
|
||||
row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): string {
|
||||
const bucket = yAxis.rows[row];
|
||||
if (!bucket) {
|
||||
return '';
|
||||
}
|
||||
return formatRowLabel(bucket, (value) =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
|
||||
);
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
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(', ');
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
export function buildBucketRows({
|
||||
counts,
|
||||
yAxis,
|
||||
row,
|
||||
column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
/** Row-major, as the renderer draws them. */
|
||||
counts: Array<ArrayLike<number | null> | undefined>;
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
column: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): HeatmapBucketRow[] {
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const rows: HeatmapBucketRow[] = [];
|
||||
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
|
||||
const index = row + offset;
|
||||
const bucket = yAxis.rows[index];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest first. Groups that contributed nothing are still listed — that is an
|
||||
* answer, and dropping the row makes the list look truncated.
|
||||
*/
|
||||
export function buildContributionRows({
|
||||
series,
|
||||
timestamp,
|
||||
row,
|
||||
color,
|
||||
}: {
|
||||
/** Only the groups the legend has enabled — they are what the cell sums. */
|
||||
series: HeatmapSeries[];
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
const counts = 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),
|
||||
color,
|
||||
count: counts[index],
|
||||
percent: total > 0 ? (counts[index] / total) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -104,21 +103,6 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {}
|
||||
|
||||
/** Not part of `TooltipProps`: it renders its own container, since none of its
|
||||
* states is the flat series list the shared `Tooltip` draws. */
|
||||
export interface HeatmapTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
/** Needed to break a summed cell down by contribution. */
|
||||
series: HeatmapSeries[];
|
||||
/** Groups the legend has enabled; the cell sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
/** Same colour the legend and the densest cells use. */
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -148,7 +148,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
@@ -180,9 +179,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (gap !== undefined) {
|
||||
axisConfig.gap = gap;
|
||||
}
|
||||
|
||||
@@ -46,13 +46,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
|
||||
// Special handling for time scales (X axis)
|
||||
if (time) {
|
||||
// An explicit range wins: the alignment below trims the tail of the window
|
||||
// to whole minutes, which is right for point-based series but drops the
|
||||
// final column of any chart whose marks span an interval.
|
||||
if (range) {
|
||||
return { [scaleKey]: { time: true, auto: false, range } };
|
||||
}
|
||||
|
||||
let minTime = this.min ?? 0;
|
||||
let maxTime = this.max ?? 0;
|
||||
|
||||
|
||||
@@ -78,8 +78,6 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import {
|
||||
clampColorSteps,
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
getMaxCount,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_OPACITY_ALPHA,
|
||||
normalizeCount,
|
||||
resolveCountDomain,
|
||||
} from '../colorScale';
|
||||
import { HeatmapColorMode, HeatmapColorScale } from '../types';
|
||||
|
||||
const SERIES_COLOR = '#4e74f8';
|
||||
|
||||
describe('getMaxCount', () => {
|
||||
it('ignores null cells', () => {
|
||||
expect(
|
||||
getMaxCount([
|
||||
[1, null, 9],
|
||||
[null, 4],
|
||||
]),
|
||||
).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty or all-null grid', () => {
|
||||
expect(getMaxCount([])).toBe(0);
|
||||
expect(getMaxCount([[null, null]])).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores non-finite counts', () => {
|
||||
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCountDomain', () => {
|
||||
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 0,
|
||||
max: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours explicit clamps', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 10,
|
||||
max: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a max at or below min', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
|
||||
).toStrictEqual({
|
||||
min: 50,
|
||||
max: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCount', () => {
|
||||
const domain = { min: 0, max: 1000 };
|
||||
|
||||
it('spreads low counts on a log scale where a linear one washes them out', () => {
|
||||
const log = (count: number): number =>
|
||||
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
|
||||
|
||||
expect(log(10)).toBeCloseTo(1 / 3, 5);
|
||||
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
|
||||
expect(
|
||||
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
|
||||
).toBeCloseTo(0.01, 5);
|
||||
});
|
||||
|
||||
it('puts 0 and 1 at the bottom of a log scale', () => {
|
||||
expect(
|
||||
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reaches the top of the scale at max on every scale', () => {
|
||||
[
|
||||
HeatmapColorScale.Log,
|
||||
HeatmapColorScale.Sqrt,
|
||||
HeatmapColorScale.Linear,
|
||||
].forEach((scale) => {
|
||||
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the square root of the linear position on a sqrt scale', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 250,
|
||||
domain: { min: 0, max: 1000 },
|
||||
scale: HeatmapColorScale.Sqrt,
|
||||
}),
|
||||
).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
it('clamps counts outside the domain', () => {
|
||||
const scale = HeatmapColorScale.Linear;
|
||||
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
|
||||
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the bottom of the scale when min equals max', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 7,
|
||||
domain: { min: 7, max: 7 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a log domain whose min and max share a decade floor', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 1,
|
||||
domain: { min: 0, max: 1 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampColorSteps', () => {
|
||||
it('clamps to the supported range', () => {
|
||||
expect(clampColorSteps(1)).toBe(2);
|
||||
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
|
||||
expect(clampColorSteps(32)).toBe(32);
|
||||
});
|
||||
|
||||
it('falls back to the default for a non-finite value', () => {
|
||||
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHeatmapColorResolver', () => {
|
||||
const build = (
|
||||
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
|
||||
isDarkMode = true,
|
||||
): ReturnType<typeof createHeatmapColorResolver> =>
|
||||
createHeatmapColorResolver({
|
||||
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
|
||||
domain: { min: 0, max: 1000 },
|
||||
isDarkMode,
|
||||
seriesColor: SERIES_COLOR,
|
||||
});
|
||||
|
||||
it('leaves null cells uncoloured so they can be hatched', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(null)).toBeNull();
|
||||
expect(resolver.positionOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom colour, not the null treatment', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
|
||||
});
|
||||
|
||||
it('emits one ramp entry per step', () => {
|
||||
expect(build({ steps: 8 }).ramp).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('maps the max count to the top of the ramp', () => {
|
||||
const resolver = build({ steps: 8 });
|
||||
|
||||
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
|
||||
});
|
||||
|
||||
it('picks different stops per theme so low counts stay near the surface', () => {
|
||||
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
|
||||
});
|
||||
|
||||
it('varies alpha in opacity mode, never below the visibility floor', () => {
|
||||
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
|
||||
|
||||
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
|
||||
// `color` drops the alpha channel from the string once it reaches 1.
|
||||
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
|
||||
});
|
||||
|
||||
it('prefers an explicit opacity fill over the series colour', () => {
|
||||
const resolver = build({
|
||||
mode: HeatmapColorMode.Opacity,
|
||||
fill: '#e5484d',
|
||||
steps: 2,
|
||||
});
|
||||
|
||||
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
|
||||
});
|
||||
|
||||
it('reports the domain it applied', () => {
|
||||
expect(build().domain).toStrictEqual({ min: 0, max: 1000 });
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
import {
|
||||
canUseLogAxis,
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnIndex,
|
||||
resolveHeatmapYAxis,
|
||||
resolveRowIndex,
|
||||
} from '../geometry';
|
||||
import { HeatmapAxisScale } from '../types';
|
||||
|
||||
const BOUNDS = [128, 256, 1024, 4096];
|
||||
|
||||
describe('canUseLogAxis', () => {
|
||||
it('accepts strictly positive bounds', () => {
|
||||
expect(canUseLogAxis(BOUNDS)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a zero or negative bound', () => {
|
||||
expect(canUseLogAxis([0, 128])).toBe(false);
|
||||
expect(canUseLogAxis([-1, 128])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty bounds', () => {
|
||||
expect(canUseLogAxis([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis', () => {
|
||||
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(BOUNDS.length + 1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
upper: 128,
|
||||
isUnderflow: true,
|
||||
isOverflow: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
|
||||
expect(rows[4]).toMatchObject({
|
||||
lower: 4096,
|
||||
isOverflow: true,
|
||||
isUnderflow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes one edge per row boundary, ascending', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(edges).toHaveLength(rows.length + 1);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('places bounds in log space so row heights are log-proportional', () => {
|
||||
const { splits, min, max } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
|
||||
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
|
||||
});
|
||||
|
||||
it('keeps bounds in value space on a linear axis', () => {
|
||||
const { splits, min } = resolveHeatmapYAxis(
|
||||
[10, 20, 30],
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([10, 20, 30]);
|
||||
// Mean gap is 10, and the underflow edge never crosses zero.
|
||||
expect(min).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts and de-duplicates bounds', () => {
|
||||
const { rows, splits } = resolveHeatmapYAxis(
|
||||
[256, 128, 256, Number.NaN],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a single bound an underflow and an overflow row', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].isUnderflow).toBe(true);
|
||||
expect(rows[1].isOverflow).toBe(true);
|
||||
expect(edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('degrades to an empty axis with no bounds', () => {
|
||||
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
|
||||
const { overflowSplit, edges } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
// A full row above the last boundary tick, so the two labels cannot collide.
|
||||
expect(overflowSplit).toBe(edges[edges.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowIndex', () => {
|
||||
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
|
||||
it('finds the row containing a value', () => {
|
||||
expect(resolveRowIndex(edges, 200)).toBe(1);
|
||||
expect(resolveRowIndex(edges, 2000)).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns a boundary to the row it opens', () => {
|
||||
expect(resolveRowIndex(edges, 256)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the last row on the top edge', () => {
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
|
||||
edges.length - 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null outside the grid', () => {
|
||||
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without at least one row', () => {
|
||||
expect(resolveRowIndex([5], 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnIndex', () => {
|
||||
const timestamps = [100, 160, 220, 280];
|
||||
const step = 60;
|
||||
|
||||
it('resolves by containment, not proximity', () => {
|
||||
// 155 is nearer to 160, but the observations at 155 belong to column 0.
|
||||
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
|
||||
});
|
||||
|
||||
it('includes the column start and excludes its end', () => {
|
||||
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
|
||||
});
|
||||
|
||||
it('covers the trailing column using the step, not the next timestamp', () => {
|
||||
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
|
||||
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null before the first column', () => {
|
||||
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no columns', () => {
|
||||
expect(resolveColumnIndex([], 100, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the last column open when the step is unknown', () => {
|
||||
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRowLabel', () => {
|
||||
const format = (value: number): string => `${value}ms`;
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
it('labels the underflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
|
||||
});
|
||||
|
||||
it('labels the overflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
|
||||
});
|
||||
|
||||
it('labels an interior row as a range', () => {
|
||||
expect(formatRowLabel(rows[1], format)).toBe('128ms – 256ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimateAxisSplits', () => {
|
||||
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const domain = { min: 0, max: 10 };
|
||||
|
||||
it('keeps every tick when they all fit', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
|
||||
it('thins to whatever fits at the available height', () => {
|
||||
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
|
||||
).toStrictEqual([0, 2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 40,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect(thinned[thinned.length - 1]).toBe(10);
|
||||
});
|
||||
|
||||
it('returns ascending positions', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 60,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
|
||||
});
|
||||
|
||||
it('thins by pixel distance, not index, so uneven rows are handled', () => {
|
||||
// Three boundaries bunched at the bottom of a wide linear domain: only the
|
||||
// first and the far-away last are far enough apart to both get labels.
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits: [1, 2, 3, 1000],
|
||||
min: 0,
|
||||
max: 1000,
|
||||
plotHeight: 200,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual([3, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the tick set alone when it cannot measure', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits,
|
||||
min: 5,
|
||||
max: 5,
|
||||
plotHeight: 400,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
// 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,
|
||||
];
|
||||
// Clock skew in ms — a logs/traces field that straddles zero.
|
||||
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
|
||||
|
||||
const PLOT_HEIGHT = 250;
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
function rowHeights(bounds: number[]): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
|
||||
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
const span = edges[edges.length - 1] - edges[0];
|
||||
const heights = edges
|
||||
.slice(1)
|
||||
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
|
||||
return Math.min(...heights);
|
||||
}
|
||||
|
||||
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
|
||||
|
||||
// A linear fallback would leave the boundaries untransformed.
|
||||
expect(splits).not.toStrictEqual([0, 5, 10]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
|
||||
const heights = rowHeights(OTEL);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
|
||||
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
|
||||
|
||||
// One axis unit — the same space a decade gets above the threshold.
|
||||
expect(heights[nearZero]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('keeps negative boundaries ascending', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('round-trips a boundary back to its bucket value', () => {
|
||||
const { splits, toBucketValue } = resolveHeatmapYAxis(
|
||||
SKEW,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(
|
||||
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
|
||||
).toStrictEqual(SKEW);
|
||||
});
|
||||
|
||||
it('derives the linear threshold from the smallest non-zero boundary', () => {
|
||||
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
|
||||
const { edges, rows } = resolveHeatmapYAxis(
|
||||
[-100, -10, 0, 10, 100],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
const crossing = rows.findIndex(
|
||||
(row) => row.lower === -10 && row.upper === 0,
|
||||
);
|
||||
|
||||
expect(edges[crossing]).toBeCloseTo(-1, 6);
|
||||
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('leaves an all-positive layout on a plain log axis', () => {
|
||||
const { splits } = resolveHeatmapYAxis(
|
||||
[128, 256, 1024],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import { resolveHeatmapGrid } from '../grid';
|
||||
import { HeatmapSeries } from '../types';
|
||||
|
||||
const BUCKETS = [10, 20];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups over two columns, each missing a value the other reports. */
|
||||
const TWO_GROUPS: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'cart',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [1, 2, 3] },
|
||||
{ timestamp: 120, counts: [null, 5, 6] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'checkout',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [10, 20, 30] },
|
||||
{ timestamp: 120, counts: [40, null, 60] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolve(
|
||||
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
|
||||
): ReturnType<typeof resolveHeatmapGrid> {
|
||||
return resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: TWO_GROUPS,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveHeatmapGrid', () => {
|
||||
it('pivots per-timestamp count arrays into one row per bucket', () => {
|
||||
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
|
||||
|
||||
// 2 boundaries describe 3 rows; each row spans both columns.
|
||||
expect(counts).toStrictEqual([
|
||||
[1, null],
|
||||
[2, 5],
|
||||
[3, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the bounds and step through untouched', () => {
|
||||
const { bounds, step } = resolve();
|
||||
|
||||
expect(bounds).toStrictEqual(BUCKETS);
|
||||
expect(step).toBe(STEP);
|
||||
});
|
||||
|
||||
it('sums every group for the combined view', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
expect(counts[2]).toStrictEqual([33, 66]);
|
||||
});
|
||||
|
||||
it('keeps one group"s count where the other has no data', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
// cart is null at 120 in row 0 while checkout reports 40.
|
||||
expect(counts[0][1]).toBe(40);
|
||||
// checkout is null at 120 in row 1 while cart reports 5.
|
||||
expect(counts[1][1]).toBe(5);
|
||||
});
|
||||
|
||||
it('reports a cell as no-data only when every group is missing it', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[null], [null]]);
|
||||
});
|
||||
|
||||
it('distinguishes a zero count from no data', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
|
||||
});
|
||||
|
||||
expect(counts[0][0]).toBe(0);
|
||||
expect(counts[1][0]).toBeNull();
|
||||
});
|
||||
|
||||
it('sums only the groups the legend has enabled', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
expect(counts[2]).toStrictEqual([3, 6]);
|
||||
});
|
||||
|
||||
it('sums every group when the legend passes nothing', () => {
|
||||
const { counts } = resolve({ visibleGroups: undefined });
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
});
|
||||
|
||||
it('ignores an enabled label that left the result', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
});
|
||||
|
||||
it('empties the grid when every group is excluded', () => {
|
||||
const { timestamps, counts } = resolve({ visibleGroups: [] });
|
||||
|
||||
expect(timestamps).toStrictEqual([]);
|
||||
expect(counts.every((row) => row.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('unions timestamps when groups do not align', () => {
|
||||
const { timestamps, counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
|
||||
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
expect(counts[0]).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('sorts columns ascending regardless of response order', () => {
|
||||
const { timestamps } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{
|
||||
label: 'a',
|
||||
points: [
|
||||
{ timestamp: 180, counts: [1, 2] },
|
||||
{ timestamp: 60, counts: [3, 4] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
});
|
||||
|
||||
it('pads rows the response left short', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10, 20, 30],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
|
||||
});
|
||||
|
||||
it('ignores counts beyond the bucket rows', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
it('degrades to an empty grid with no buckets or no series', () => {
|
||||
expect(resolve({ buckets: [] })).toStrictEqual({
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
});
|
||||
expect(resolve({ series: [] }).counts).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,289 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
|
||||
import { resolveHeatmapYAxis } from '../geometry';
|
||||
import { createHeatmapHooks } from '../heatmapPlugin';
|
||||
import { HeatmapAxisScale, HeatmapCell } from '../types';
|
||||
|
||||
const BOUNDS = [100, 1000];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
const TIMESTAMPS = [1000, 1060, 1120];
|
||||
const STEP = 60;
|
||||
const PLOT_WIDTH = 300;
|
||||
const PLOT_HEIGHT = 300;
|
||||
|
||||
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
|
||||
const DATA = [
|
||||
TIMESTAMPS,
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
] as unknown as uPlot.AlignedData;
|
||||
|
||||
interface FakeContext {
|
||||
fillRect: jest.Mock;
|
||||
fills: string[];
|
||||
}
|
||||
|
||||
interface FakePlot {
|
||||
plot: uPlot;
|
||||
context: FakeContext;
|
||||
setSeries: jest.Mock;
|
||||
over: HTMLDivElement;
|
||||
}
|
||||
|
||||
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
|
||||
const over = document.createElement('div');
|
||||
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
|
||||
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
|
||||
|
||||
const fills: string[] = [];
|
||||
const fillRect = jest.fn();
|
||||
const context = { fills, fillRect };
|
||||
const setSeries = jest.fn();
|
||||
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
createPattern: jest.fn(() => null),
|
||||
set fillStyle(value: string) {
|
||||
fills.push(value);
|
||||
},
|
||||
fillRect: (...args: number[]): void => {
|
||||
fillRect(...args);
|
||||
},
|
||||
};
|
||||
|
||||
const plot = {
|
||||
data: DATA,
|
||||
cursor,
|
||||
over,
|
||||
setSeries,
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
|
||||
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
|
||||
// x grows left to right; y is inverted, so the highest bucket is at the top.
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
|
||||
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
|
||||
};
|
||||
|
||||
return { plot: plot as unknown as uPlot, context, setSeries, over };
|
||||
}
|
||||
|
||||
function createHooks(
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void,
|
||||
dimOnHover = true,
|
||||
): ReturnType<typeof createHeatmapHooks> {
|
||||
return createHeatmapHooks({
|
||||
yAxis: Y_AXIS,
|
||||
step: STEP,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
}
|
||||
|
||||
describe('heatmap renderer — lifecycle', () => {
|
||||
it('mounts the hover overlay into the plot overlay and tears it down', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, over } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
hooks.destroy(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — draw', () => {
|
||||
it('paints every cell of every visible column', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
|
||||
// available under jsdom.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
|
||||
const hooks = createHooks();
|
||||
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = zeroed;
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(9);
|
||||
expect(new Set(context.fills).size).toBe(1);
|
||||
});
|
||||
|
||||
it('skips columns outside the current x range', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { scales: unknown }).scales = {
|
||||
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
|
||||
};
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// Only the first two columns overlap the range; the third starts past its end.
|
||||
// 2 columns x 3 rows, less the null cell in column 1.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('draws nothing without columns', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = [[]];
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — hover', () => {
|
||||
it('focuses the hovered row and reports the cell under the cursor', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
// Left third of the plot is column 0; the top third is the overflow row.
|
||||
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
|
||||
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('reports a data gap as a null count instead of zero', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({
|
||||
left: PLOT_WIDTH / 2,
|
||||
top: PLOT_HEIGHT / 2,
|
||||
});
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({
|
||||
row: 1,
|
||||
column: 1,
|
||||
count: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-report the same cell', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the overlay over the hovered cell and dims around it', () => {
|
||||
const hooks = createHooks(undefined, true);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.style.display).toBe('block');
|
||||
// Column 0 spans the left third of a 300px plot.
|
||||
expect(overlay?.lastElementChild).toHaveStyle({
|
||||
left: '0px',
|
||||
width: '100px',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses the dim rects when dimming is off', () => {
|
||||
const hooks = createHooks(undefined, false);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.firstElementChild).toHaveStyle({
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
});
|
||||
});
|
||||
|
||||
it('releases focus and hides the overlay when the cursor leaves', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: -10,
|
||||
top: -10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
expect(
|
||||
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
|
||||
?.style.display,
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('clears the hover when the cursor is inside the plot but past the last column', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { data: unknown }).data = [[], [], [], []];
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: 10,
|
||||
top: 10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { getPaletteStops } from '../palettes';
|
||||
import { HeatmapColorPalette } from '../types';
|
||||
|
||||
const ALL_PALETTES = Object.values(HeatmapColorPalette);
|
||||
|
||||
/** Perceived brightness, good enough to tell a ramp's ends apart. */
|
||||
function luminance(hex: string): number {
|
||||
const value = parseInt(hex.slice(1), 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
describe('getPaletteStops', () => {
|
||||
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
expect(stops).toHaveLength(9);
|
||||
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
|
||||
});
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s climbs from dark to bright on a dark panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
// Low counts must sit near the surface, whichever direction the ramp is
|
||||
// stored in — otherwise empty cells become the loudest thing on screen.
|
||||
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s falls from pale to saturated on a light panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, false);
|
||||
|
||||
expect(luminance(stops[0])).toBeGreaterThan(
|
||||
luminance(stops[stops.length - 1]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
|
||||
// Only the polarity flips; the palette itself is theme-independent.
|
||||
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
|
||||
getPaletteStops(palette, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('never mutates the stored ramp when reversing it', () => {
|
||||
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
|
||||
expect(first).toStrictEqual(second);
|
||||
});
|
||||
|
||||
it('falls back to the first ramp for an unknown palette', () => {
|
||||
const unknown = 'nope' as HeatmapColorPalette;
|
||||
|
||||
expect(getPaletteStops(unknown, true)).toStrictEqual(
|
||||
getPaletteStops(HeatmapColorPalette.Ice, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
|
||||
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
|
||||
|
||||
// Every stop is a grey: red, green and blue channels stay equal.
|
||||
stops.forEach((stop) => {
|
||||
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
|
||||
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { Color as DesignToken } from '@signozhq/design-tokens';
|
||||
import Color from 'color';
|
||||
|
||||
import { getPaletteStops } from './palettes';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapColorOptions,
|
||||
HeatmapColorScale,
|
||||
HeatmapColorPalette,
|
||||
} from './types';
|
||||
|
||||
export const MIN_COLOR_STEPS = 2;
|
||||
export const MAX_COLOR_STEPS = 128;
|
||||
export const DEFAULT_COLOR_STEPS = 64;
|
||||
|
||||
/** Without a floor, the lowest counts read as "no data". */
|
||||
export const MIN_OPACITY_ALPHA = 0.1;
|
||||
|
||||
/** Used when neither an explicit fill nor a series colour is available. */
|
||||
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
|
||||
mode: HeatmapColorMode.Palette,
|
||||
scale: HeatmapColorScale.Log,
|
||||
minCount: null,
|
||||
maxCount: null,
|
||||
palette: HeatmapColorPalette.Lava,
|
||||
steps: DEFAULT_COLOR_STEPS,
|
||||
fill: '',
|
||||
};
|
||||
|
||||
export interface CountDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Highest count, ignoring `null`. 0 for an empty grid. */
|
||||
export function getMaxCount(counts: Array<Array<number | null>>): number {
|
||||
let max = 0;
|
||||
for (const row of counts) {
|
||||
for (const count of row) {
|
||||
if (count !== null && Number.isFinite(count) && count > max) {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
|
||||
export function resolveCountDomain(
|
||||
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
|
||||
counts: Array<Array<number | null>>,
|
||||
): CountDomain {
|
||||
const min = options.minCount ?? 0;
|
||||
const max = options.maxCount ?? getMaxCount(counts);
|
||||
return max > min ? { min, max } : { min, max: min };
|
||||
}
|
||||
|
||||
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
|
||||
* all-zero grid renders at the bottom rather than disappearing. */
|
||||
export function normalizeCount({
|
||||
count,
|
||||
domain,
|
||||
scale,
|
||||
}: {
|
||||
count: number;
|
||||
domain: CountDomain;
|
||||
scale: HeatmapColorScale;
|
||||
}): number {
|
||||
const { min, max } = domain;
|
||||
if (!(max > min)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return (Math.log10(Math.max(clamped, 1)) - logMin) / (logMax - logMin);
|
||||
}
|
||||
|
||||
const linear = (clamped - min) / (max - min);
|
||||
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
|
||||
}
|
||||
|
||||
export function clampColorSteps(steps: number): number {
|
||||
if (!Number.isFinite(steps)) {
|
||||
return DEFAULT_COLOR_STEPS;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
|
||||
}
|
||||
|
||||
/** Colour at `t` (0..1) along a multi-stop ramp. */
|
||||
function sampleStops(stops: string[], t: number): string {
|
||||
if (stops.length === 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (stops.length === 1) {
|
||||
return stops[0];
|
||||
}
|
||||
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
|
||||
const lower = Math.min(Math.floor(scaled), stops.length - 2);
|
||||
return Color(stops[lower])
|
||||
.mix(Color(stops[lower + 1]), scaled - lower)
|
||||
.hex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
|
||||
* fill at full strength. Depends only on the options, not on the data, so callers
|
||||
* can read it before a grid exists.
|
||||
*/
|
||||
export function resolveExtremeColor({
|
||||
options,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
seriesColor: string;
|
||||
}): string {
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
|
||||
export interface HeatmapColorResolver {
|
||||
/** `null` for a `null` count, which must be hatched. */
|
||||
colorFor: (count: number | null) => string | null;
|
||||
/** 0..1, or `null` for a `null` count. */
|
||||
positionOf: (count: number | null) => number | null;
|
||||
/** Low to high. The colour bar renders exactly these. */
|
||||
ramp: string[];
|
||||
domain: CountDomain;
|
||||
}
|
||||
|
||||
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
|
||||
* fill, so the grid matches its group's legend swatch. */
|
||||
export function createHeatmapColorResolver({
|
||||
options,
|
||||
domain,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
domain: CountDomain;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `options.fill` is empty. */
|
||||
seriesColor: string;
|
||||
}): HeatmapColorResolver {
|
||||
const steps = clampColorSteps(options.steps);
|
||||
const positions = Array.from({ length: steps }, (_, index) =>
|
||||
steps === 1 ? 0 : index / (steps - 1),
|
||||
);
|
||||
|
||||
let ramp: string[];
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
|
||||
ramp = positions.map((t) =>
|
||||
base
|
||||
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
|
||||
.rgb()
|
||||
.string(),
|
||||
);
|
||||
} else {
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
ramp = positions.map((t) => sampleStops(stops, t));
|
||||
}
|
||||
|
||||
const positionOf = (count: number | null): number | null => {
|
||||
if (count === null || !Number.isFinite(count)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeCount({ count, domain, scale: options.scale });
|
||||
};
|
||||
|
||||
return {
|
||||
positionOf,
|
||||
colorFor: (count): string | null => {
|
||||
const t = positionOf(count);
|
||||
if (t === null) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.min(Math.floor(t * steps), steps - 1);
|
||||
return ramp[index];
|
||||
},
|
||||
ramp,
|
||||
domain,
|
||||
};
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
|
||||
|
||||
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
|
||||
const FALLBACK_LOG_RATIO = 2;
|
||||
|
||||
const EMPTY_Y_AXIS: HeatmapYAxis = {
|
||||
rows: [],
|
||||
edges: [],
|
||||
splits: [],
|
||||
overflowSplit: null,
|
||||
toBucketValue: (axisValue: number): number => axisValue,
|
||||
min: 0,
|
||||
max: 1,
|
||||
};
|
||||
|
||||
/** Ascending, finite, de-duplicated boundaries. */
|
||||
function normalizeBounds(bounds: number[]): number[] {
|
||||
const sorted = bounds
|
||||
.filter((bound) => Number.isFinite(bound))
|
||||
.sort((a, b) => a - b);
|
||||
return sorted.filter(
|
||||
(bound, index) => index === 0 || bound !== sorted[index - 1],
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a plain log axis can place every boundary. */
|
||||
export function canUseLogAxis(bounds: number[]): boolean {
|
||||
return bounds.length > 0 && bounds.every((bound) => bound > 0);
|
||||
}
|
||||
|
||||
interface AxisTransform {
|
||||
toAxisValue: (value: number) => number;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
}
|
||||
|
||||
const LINEAR_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => value,
|
||||
toBucketValue: (axisValue) => axisValue,
|
||||
};
|
||||
|
||||
const LOG_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => Math.log10(value),
|
||||
toBucketValue: (axisValue) => 10 ** axisValue,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
|
||||
* bucket layout already declares it, so it never needs to be configured.
|
||||
*/
|
||||
function resolveLinearThreshold(bounds: number[]): number {
|
||||
let threshold = Number.POSITIVE_INFINITY;
|
||||
for (const bound of bounds) {
|
||||
const magnitude = Math.abs(bound);
|
||||
if (magnitude > 0 && magnitude < threshold) {
|
||||
threshold = magnitude;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(threshold) ? threshold : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
|
||||
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
|
||||
* deltas, balances — which a plain log cannot place at all, and which a linear axis
|
||||
* squeezes into sub-pixel rows exactly where the interesting data sits.
|
||||
*
|
||||
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
|
||||
* so it lands on a row edge, and row edges are already discrete.
|
||||
*/
|
||||
function createSymlogTransform(threshold: number): AxisTransform {
|
||||
return {
|
||||
toAxisValue: (value) =>
|
||||
Math.abs(value) <= threshold
|
||||
? value / threshold
|
||||
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
|
||||
toBucketValue: (axisValue) =>
|
||||
Math.abs(axisValue) <= 1
|
||||
? axisValue * threshold
|
||||
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
if (!bounds.some((bound) => bound !== 0)) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
return createSymlogTransform(resolveLinearThreshold(bounds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
|
||||
*/
|
||||
function resolveOuterEdges(
|
||||
bounds: number[],
|
||||
transform: AxisTransform,
|
||||
isLinear: boolean,
|
||||
): { lower: number; upper: number } {
|
||||
const first = bounds[0];
|
||||
const last = bounds[bounds.length - 1];
|
||||
|
||||
if (isLinear) {
|
||||
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
|
||||
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 };
|
||||
}
|
||||
|
||||
const axisFirst = transform.toAxisValue(first);
|
||||
const axisLast = transform.toAxisValue(last);
|
||||
const fallback = Math.log10(FALLBACK_LOG_RATIO);
|
||||
const gap =
|
||||
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
|
||||
const safeGap = gap > 0 ? gap : fallback;
|
||||
|
||||
return {
|
||||
lower: transform.toBucketValue(axisFirst - safeGap),
|
||||
upper: transform.toBucketValue(axisLast + safeGap),
|
||||
};
|
||||
}
|
||||
|
||||
/** N boundaries produce N+1 rows: an underflow row below the first, and the
|
||||
* `+Inf` overflow row above the last. */
|
||||
export function resolveHeatmapYAxis(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): HeatmapYAxis {
|
||||
const normalized = normalizeBounds(bounds);
|
||||
if (normalized.length === 0) {
|
||||
return EMPTY_Y_AXIS;
|
||||
}
|
||||
|
||||
const transform = resolveAxisTransform(normalized, scale);
|
||||
const isLinear = transform === LINEAR_TRANSFORM;
|
||||
const { toAxisValue, toBucketValue } = transform;
|
||||
|
||||
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
|
||||
const last = normalized[normalized.length - 1];
|
||||
|
||||
const rows: HeatmapRow[] = [
|
||||
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
|
||||
];
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
rows.push({
|
||||
lower: normalized[index - 1],
|
||||
upper: normalized[index],
|
||||
isUnderflow: false,
|
||||
isOverflow: false,
|
||||
});
|
||||
}
|
||||
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
|
||||
|
||||
const edges = [
|
||||
toAxisValue(lower),
|
||||
...normalized.map(toAxisValue),
|
||||
toAxisValue(upper),
|
||||
];
|
||||
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: normalized.map(toAxisValue),
|
||||
overflowSplit: toAxisValue(upper),
|
||||
toBucketValue,
|
||||
min: edges[0],
|
||||
max: edges[edges.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
|
||||
export function resolveRowIndex(
|
||||
edges: number[],
|
||||
axisValue: number,
|
||||
): number | null {
|
||||
if (edges.length < 2) {
|
||||
return null;
|
||||
}
|
||||
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = edges.length - 2;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (axisValue < edges[mid]) {
|
||||
high = mid - 1;
|
||||
} else if (axisValue >= edges[mid + 1]) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
return mid;
|
||||
}
|
||||
}
|
||||
// Exactly on the top edge.
|
||||
return edges.length - 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
|
||||
* snaps to the closest boundary and would report the next column as soon as the
|
||||
* cursor passed a cell's midpoint.
|
||||
*/
|
||||
export function resolveColumnIndex(
|
||||
timestamps: ArrayLike<number>,
|
||||
xValue: number,
|
||||
step: number,
|
||||
): number | null {
|
||||
if (timestamps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = timestamps.length - 1;
|
||||
let candidate = -1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (timestamps[mid] <= xValue) {
|
||||
candidate = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate < 0) {
|
||||
return null;
|
||||
}
|
||||
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
|
||||
return xValue < timestamps[candidate] + width ? candidate : null;
|
||||
}
|
||||
|
||||
/** The open-ended rows are labelled by their one real boundary; the synthetic
|
||||
* edge is a drawing device, not a value. */
|
||||
export function formatRowLabel(
|
||||
row: HeatmapRow,
|
||||
formatValue: (value: number) => string,
|
||||
): string {
|
||||
if (row.isOverflow) {
|
||||
return `> ${formatValue(row.lower)}`;
|
||||
}
|
||||
if (row.isUnderflow) {
|
||||
return `≤ ${formatValue(row.upper)}`;
|
||||
}
|
||||
return `${formatValue(row.lower)} – ${formatValue(row.upper)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
|
||||
* index, since linear rows are not the same height, and walks down from the top
|
||||
* so the `∞` edge survives whatever else is dropped.
|
||||
*/
|
||||
export function decimateAxisSplits({
|
||||
splits,
|
||||
min,
|
||||
max,
|
||||
plotHeight,
|
||||
minGapPx,
|
||||
}: {
|
||||
/** Candidates in axis space, ascending. */
|
||||
splits: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
/** Plotting area height, in CSS pixels. */
|
||||
plotHeight: number;
|
||||
minGapPx: number;
|
||||
}): number[] {
|
||||
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
|
||||
return splits;
|
||||
}
|
||||
|
||||
const pixelsPerUnit = plotHeight / (max - min);
|
||||
const kept: number[] = [];
|
||||
let lastPosition = 0;
|
||||
|
||||
for (let index = splits.length - 1; index >= 0; index -= 1) {
|
||||
// Axis values grow upward, pixel offsets downward.
|
||||
const position = (max - splits[index]) * pixelsPerUnit;
|
||||
if (kept.length === 0 || position - lastPosition >= minGapPx) {
|
||||
kept.push(splits[index]);
|
||||
lastPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
return kept.reverse();
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { HeatmapGrid, HeatmapSeries } from './types';
|
||||
|
||||
const EMPTY_GRID: HeatmapGrid = {
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
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[],
|
||||
visibleGroups: string[] | undefined,
|
||||
): HeatmapSeries[] {
|
||||
if (visibleGroups === undefined) {
|
||||
return series;
|
||||
}
|
||||
const allowed = new Set(visibleGroups);
|
||||
return series.filter((entry) => allowed.has(entry.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivots the response's column-major counts into the row-major grid the renderer
|
||||
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
|
||||
* needs no extra request. A cell is `null` only when no group contributed to it.
|
||||
*/
|
||||
export function resolveHeatmapGrid({
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
}: {
|
||||
buckets: number[];
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
series: HeatmapSeries[];
|
||||
/** Labels the legend has enabled. `undefined` sums every group. */
|
||||
visibleGroups?: string[];
|
||||
}): HeatmapGrid {
|
||||
if (buckets.length === 0 || series.length === 0) {
|
||||
return EMPTY_GRID;
|
||||
}
|
||||
|
||||
const selected = resolveVisible(series, visibleGroups);
|
||||
|
||||
// Groups are not guaranteed to share timestamps, so the columns are their union.
|
||||
const timestampSet = new Set<number>();
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => timestampSet.add(point.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
|
||||
|
||||
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
|
||||
const rowCount = buckets.length + 1;
|
||||
const counts: Array<Array<number | null>> = Array.from(
|
||||
{ length: rowCount },
|
||||
() => new Array<number | null>(timestamps.length).fill(null),
|
||||
);
|
||||
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => {
|
||||
const column = columnOf.get(point.timestamp);
|
||||
if (column === undefined) {
|
||||
return;
|
||||
}
|
||||
point.counts.forEach((count, row) => {
|
||||
if (row >= rowCount || count === null || count === undefined) {
|
||||
return;
|
||||
}
|
||||
counts[row][column] = (counts[row][column] ?? 0) + count;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { bounds: buckets, timestamps, step, counts };
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
HeatmapColorResolver,
|
||||
resolveCountDomain,
|
||||
} from './colorScale';
|
||||
import { resolveColumnIndex, resolveRowIndex } from './geometry';
|
||||
import {
|
||||
createHoverOverlay,
|
||||
HeatmapHoverOverlay,
|
||||
showHoverOverlay,
|
||||
} from './hoverOverlay';
|
||||
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
|
||||
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
|
||||
|
||||
export interface HeatmapRenderOptions {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
colors: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `colors.fill` is empty. */
|
||||
seriesColor: string;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
/** `null` when the cursor leaves. */
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
|
||||
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
|
||||
* TooltipPlugin's so the focused row is resolved when the tooltip positions
|
||||
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
|
||||
*/
|
||||
export interface HeatmapHooks {
|
||||
init: (u: uPlot) => void;
|
||||
draw: (u: uPlot) => void;
|
||||
setCursor: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
}
|
||||
|
||||
export function createHeatmapHooks({
|
||||
yAxis,
|
||||
step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover = true,
|
||||
onHoverChange,
|
||||
}: HeatmapRenderOptions): HeatmapHooks {
|
||||
let overlay: HeatmapHoverOverlay | null = null;
|
||||
let hovered: HeatmapCell | null = null;
|
||||
let hatchPattern: CanvasPattern | null = null;
|
||||
|
||||
// On auto, the domain comes from the data, but these hooks are captured once at
|
||||
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
|
||||
// rather than forcing a rebuild.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedResolver: HeatmapColorResolver | null = null;
|
||||
|
||||
function getResolver(u: uPlot): HeatmapColorResolver {
|
||||
if (cachedResolver && cachedData === u.data) {
|
||||
return cachedResolver;
|
||||
}
|
||||
cachedResolver = createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(
|
||||
colors,
|
||||
u.data.slice(1) as Array<Array<number | null>>,
|
||||
),
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
});
|
||||
cachedData = u.data;
|
||||
return cachedResolver;
|
||||
}
|
||||
|
||||
function clearHover(u: uPlot): void {
|
||||
if (overlay) {
|
||||
overlay.container.style.display = 'none';
|
||||
}
|
||||
if (hovered === null) {
|
||||
return;
|
||||
}
|
||||
hovered = null;
|
||||
u.setSeries(null, { focus: true });
|
||||
onHoverChange?.(null);
|
||||
}
|
||||
|
||||
return {
|
||||
init: (u: uPlot): void => {
|
||||
overlay = createHoverOverlay(isDarkMode);
|
||||
u.over.appendChild(overlay.container);
|
||||
},
|
||||
|
||||
draw: (u: uPlot): void => {
|
||||
const timestamps = u.data[0] as ArrayLike<number> | undefined;
|
||||
if (!timestamps?.length || yAxis.rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { ctx } = u;
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
|
||||
ctx.restore();
|
||||
|
||||
drawOverflowBoundary({ u, yAxis, isDarkMode });
|
||||
},
|
||||
|
||||
setCursor: (u: uPlot): void => {
|
||||
const { left = -10, top = -10 } = u.cursor;
|
||||
if (left < 0 || top < 0) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
|
||||
const column = resolveColumnIndex(
|
||||
u.data[0] as ArrayLike<number>,
|
||||
u.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
if (hovered?.row === row && hovered?.column === column) {
|
||||
return;
|
||||
}
|
||||
|
||||
hovered = {
|
||||
row,
|
||||
column,
|
||||
count:
|
||||
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null,
|
||||
};
|
||||
// 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.
|
||||
u.setSeries(row + 1, { focus: true });
|
||||
if (overlay) {
|
||||
showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim: dimOnHover,
|
||||
});
|
||||
}
|
||||
onHoverChange?.(hovered);
|
||||
},
|
||||
|
||||
destroy: (): void => {
|
||||
overlay?.container.remove();
|
||||
overlay = null;
|
||||
hovered = null;
|
||||
hatchPattern = null;
|
||||
cachedData = null;
|
||||
cachedResolver = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
const HIGHLIGHT_BORDER_WIDTH = 1;
|
||||
/** ~55% alpha. */
|
||||
const DIM_ALPHA = '8C';
|
||||
|
||||
export interface HeatmapHoverOverlay {
|
||||
container: HTMLDivElement;
|
||||
highlight: HTMLDivElement;
|
||||
/** Four corner rects whose complement is the hovered row/column cross. */
|
||||
dims: HTMLDivElement[];
|
||||
}
|
||||
|
||||
function createOverlayElement(): HTMLDivElement {
|
||||
const element = document.createElement('div');
|
||||
element.style.position = 'absolute';
|
||||
element.style.pointerEvents = 'none';
|
||||
return element;
|
||||
}
|
||||
|
||||
function setRect(
|
||||
element: HTMLDivElement,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
element.style.width = `${Math.max(0, width)}px`;
|
||||
element.style.height = `${Math.max(0, height)}px`;
|
||||
}
|
||||
|
||||
/** Kept out of the canvas so moving between cells repositions a few nodes
|
||||
* instead of repainting the grid. */
|
||||
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
|
||||
const container = createOverlayElement();
|
||||
container.style.inset = '0';
|
||||
container.style.display = 'none';
|
||||
container.setAttribute('data-testid', 'heatmap-hover-overlay');
|
||||
|
||||
const dimColor = `${
|
||||
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
|
||||
}${DIM_ALPHA}`;
|
||||
const dims = Array.from({ length: 4 }, () => {
|
||||
const dim = createOverlayElement();
|
||||
dim.style.background = dimColor;
|
||||
container.appendChild(dim);
|
||||
return dim;
|
||||
});
|
||||
|
||||
const highlight = createOverlayElement();
|
||||
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
|
||||
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
|
||||
}`;
|
||||
highlight.style.boxSizing = 'border-box';
|
||||
container.appendChild(highlight);
|
||||
|
||||
return { container, highlight, dims };
|
||||
}
|
||||
|
||||
/** Positions the highlight, and the four corner rects so only the hovered row
|
||||
* and column stay at full contrast. */
|
||||
export function showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim,
|
||||
}: {
|
||||
overlay: HeatmapHoverOverlay;
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
row: number;
|
||||
column: number;
|
||||
dim: boolean;
|
||||
}): void {
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const width = u.over.clientWidth;
|
||||
const height = u.over.clientHeight;
|
||||
|
||||
const cellLeft = u.valToPos(timestamps[column], 'x');
|
||||
const cellRight = u.valToPos(timestamps[column] + step, 'x');
|
||||
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
|
||||
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
|
||||
|
||||
setRect(
|
||||
overlay.highlight,
|
||||
cellLeft,
|
||||
cellTop,
|
||||
cellRight - cellLeft,
|
||||
cellBottom - cellTop,
|
||||
);
|
||||
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
|
||||
if (dim) {
|
||||
setRect(topLeft, 0, 0, cellLeft, cellTop);
|
||||
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
|
||||
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
|
||||
setRect(
|
||||
bottomRight,
|
||||
cellRight,
|
||||
cellBottom,
|
||||
width - cellRight,
|
||||
height - cellBottom,
|
||||
);
|
||||
} else {
|
||||
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
overlay.container.style.display = 'block';
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapColorResolver } from './colorScale';
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
/** Cells at least this wide/tall keep a hairline separator. */
|
||||
const MIN_CELL_SIZE_FOR_GAP = 4;
|
||||
const HATCH_TILE_SIZE = 6;
|
||||
const OVERFLOW_DASH: [number, number] = [4, 3];
|
||||
|
||||
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
|
||||
* scrape outage reads as a quiet period. */
|
||||
export function createHatchPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
isDarkMode: boolean,
|
||||
): CanvasPattern | null {
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = size;
|
||||
tile.height = size;
|
||||
|
||||
const tileCtx = tile.getContext('2d');
|
||||
if (!tileCtx) {
|
||||
return null;
|
||||
}
|
||||
tileCtx.strokeStyle = isDarkMode
|
||||
? `${Color.BG_VANILLA_400}59`
|
||||
: `${Color.BG_INK_300}40`;
|
||||
tileCtx.lineWidth = Math.max(1, pxRatio);
|
||||
tileCtx.beginPath();
|
||||
// Three strokes keep the pattern continuous across tile seams.
|
||||
tileCtx.moveTo(0, size);
|
||||
tileCtx.lineTo(size, 0);
|
||||
tileCtx.moveTo(-size / 2, size / 2);
|
||||
tileCtx.lineTo(size / 2, -size / 2);
|
||||
tileCtx.moveTo(size / 2, size * 1.5);
|
||||
tileCtx.lineTo(size * 1.5, size / 2);
|
||||
tileCtx.stroke();
|
||||
|
||||
return ctx.createPattern(tile, 'repeat');
|
||||
}
|
||||
|
||||
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function drawCells({
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
resolver,
|
||||
hatchPattern,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
resolver: HeatmapColorResolver;
|
||||
hatchPattern: CanvasPattern | null;
|
||||
}): void {
|
||||
const { ctx } = u;
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const { rows, edges } = yAxis;
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
|
||||
const xMin = u.scales.x.min ?? timestamps[0];
|
||||
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
|
||||
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
|
||||
|
||||
for (let column = 0; column < timestamps.length; column += 1) {
|
||||
const columnStart = timestamps[column];
|
||||
const columnEnd = columnStart + step;
|
||||
if (columnEnd < xMin || columnStart > xMax) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const left = u.valToPos(columnStart, 'x', true);
|
||||
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
|
||||
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
const width = Math.max(1, rawWidth - gapX);
|
||||
|
||||
for (let row = 0; row < rows.length; row += 1) {
|
||||
const top = rowEdgePositions[row + 1];
|
||||
const rawHeight = rowEdgePositions[row] - top;
|
||||
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
|
||||
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
];
|
||||
const fill = resolver.colorFor(count ?? null);
|
||||
|
||||
if (fill === null && hatchPattern === null) {
|
||||
continue;
|
||||
}
|
||||
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
|
||||
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
|
||||
* should not be compared with the real buckets. */
|
||||
export function drawOverflowBoundary({
|
||||
u,
|
||||
yAxis,
|
||||
isDarkMode,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
isDarkMode: boolean;
|
||||
}): void {
|
||||
const overflowIndex = yAxis.rows.length - 1;
|
||||
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctx } = u;
|
||||
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash(OVERFLOW_DASH);
|
||||
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
|
||||
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(u.bbox.left, y);
|
||||
ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { HeatmapColorPalette } from './types';
|
||||
|
||||
interface PaletteDefinition {
|
||||
/** Evenly spaced, one end of the ramp to the other. */
|
||||
stops: string[];
|
||||
/** `true` when `stops[0]` is the dark end. */
|
||||
darkFirst: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop values come from the long-established public palette families —
|
||||
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
|
||||
*/
|
||||
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
|
||||
[HeatmapColorPalette.Ice]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fbff',
|
||||
'#deebf7',
|
||||
'#c3dbee',
|
||||
'#9cc8e2',
|
||||
'#6daed5',
|
||||
'#4391c6',
|
||||
'#2271b4',
|
||||
'#0c5198',
|
||||
'#08306b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Moss]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fcf5',
|
||||
'#e3f4de',
|
||||
'#c6e8bf',
|
||||
'#a0d89b',
|
||||
'#73c378',
|
||||
'#45aa5d',
|
||||
'#228b45',
|
||||
'#066b2d',
|
||||
'#00441b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Rust]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff5f0',
|
||||
'#feddcf',
|
||||
'#fcbaa1',
|
||||
'#fc9273',
|
||||
'#f9694c',
|
||||
'#eb3d2f',
|
||||
'#cb1c1e',
|
||||
'#a10e15',
|
||||
'#67000d',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Graphite]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffff',
|
||||
'#efefef',
|
||||
'#d8d8d8',
|
||||
'#bbbbbb',
|
||||
'#979797',
|
||||
'#737373',
|
||||
'#505050',
|
||||
'#262626',
|
||||
'#000000',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Ember]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffcc',
|
||||
'#ffeda0',
|
||||
'#fed676',
|
||||
'#feb250',
|
||||
'#fd893c',
|
||||
'#f8502b',
|
||||
'#e11e20',
|
||||
'#b90424',
|
||||
'#800026',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lagoon]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffd9',
|
||||
'#eaf7b8',
|
||||
'#c1e7b5',
|
||||
'#81cebb',
|
||||
'#45b4c2',
|
||||
'#248fbd',
|
||||
'#2260a9',
|
||||
'#20378d',
|
||||
'#081d58',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Orchid]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff7f3',
|
||||
'#fddfdc',
|
||||
'#fcc3c3',
|
||||
'#fa9cb4',
|
||||
'#f369a3',
|
||||
'#da3495',
|
||||
'#ad0a81',
|
||||
'#7b0176',
|
||||
'#49006a',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Verdant]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#440154',
|
||||
'#472d7b',
|
||||
'#3b528b',
|
||||
'#2c728e',
|
||||
'#21918c',
|
||||
'#28ae80',
|
||||
'#5ec962',
|
||||
'#addc30',
|
||||
'#fde725',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lava]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#000004',
|
||||
'#1d1147',
|
||||
'#51127c',
|
||||
'#832681',
|
||||
'#b73779',
|
||||
'#e75263',
|
||||
'#fc8961',
|
||||
'#fec488',
|
||||
'#fcfdbf',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Beacon]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#002051',
|
||||
'#11366c',
|
||||
'#3c4d6e',
|
||||
'#62646f',
|
||||
'#7f7c75',
|
||||
'#9a9478',
|
||||
'#bbaf71',
|
||||
'#e2cb5c',
|
||||
'#fdea45',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Stops oriented low-count first for the active theme. At the wrong polarity,
|
||||
* empty cells become the loudest thing on screen. */
|
||||
export function getPaletteStops(
|
||||
palette: HeatmapColorPalette,
|
||||
isDarkMode: boolean,
|
||||
): string[] {
|
||||
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
|
||||
return definition.darkFirst === isDarkMode
|
||||
? definition.stops
|
||||
: [...definition.stops].reverse();
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
export enum HeatmapColorScale {
|
||||
Log = 'log',
|
||||
Sqrt = 'sqrt',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export enum HeatmapColorMode {
|
||||
Palette = 'palette',
|
||||
Opacity = 'opacity',
|
||||
}
|
||||
|
||||
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
|
||||
* read as a threshold that does not exist. */
|
||||
export enum HeatmapColorPalette {
|
||||
Ice = 'ice',
|
||||
Moss = 'moss',
|
||||
Rust = 'rust',
|
||||
Graphite = 'graphite',
|
||||
Ember = 'ember',
|
||||
Lagoon = 'lagoon',
|
||||
Orchid = 'orchid',
|
||||
Verdant = 'verdant',
|
||||
Lava = 'lava',
|
||||
Beacon = 'beacon',
|
||||
}
|
||||
|
||||
export interface HeatmapColorOptions {
|
||||
mode: HeatmapColorMode;
|
||||
scale: HeatmapColorScale;
|
||||
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
|
||||
minCount: number | null;
|
||||
/** `null` derives it from the grid's highest count. */
|
||||
maxCount: number | null;
|
||||
palette: HeatmapColorPalette;
|
||||
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
|
||||
* column width in seconds. */
|
||||
steps: number;
|
||||
/** Opacity mode. Empty falls back to the caller's series colour. */
|
||||
fill: string;
|
||||
}
|
||||
|
||||
/** Row-height distribution of the bucket axis. */
|
||||
export enum HeatmapAxisScale {
|
||||
Log = 'log',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesPoint {
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
|
||||
counts: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HeatmapSeries {
|
||||
/** Group label, as the legend names 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[];
|
||||
}
|
||||
|
||||
/** Counts pivoted into rows and aligned to one column axis. Internal to the
|
||||
* chart, which resolves it from `buckets` and `series`. */
|
||||
export interface HeatmapGrid {
|
||||
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
|
||||
bounds: number[];
|
||||
/** Column starts, in seconds. */
|
||||
timestamps: number[];
|
||||
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
|
||||
* and the last column has no successor to infer it from. */
|
||||
step: number;
|
||||
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
|
||||
* at the bottom of the scale — conflating them hides an outage. */
|
||||
counts: Array<Array<number | null>>;
|
||||
}
|
||||
|
||||
export interface HeatmapRow {
|
||||
/** Synthetic on the underflow row. */
|
||||
lower: number;
|
||||
/** Synthetic on the overflow row. */
|
||||
upper: number;
|
||||
isUnderflow: boolean;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
|
||||
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
|
||||
* ticks and uPlot's decade-only label filter cannot hide them. */
|
||||
export interface HeatmapYAxis {
|
||||
rows: HeatmapRow[];
|
||||
/** Row edges, ascending. Length is `rows.length + 1`. */
|
||||
edges: number[];
|
||||
/** Real bucket boundaries — one tick each. */
|
||||
splits: number[];
|
||||
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
|
||||
* which would sit half a row from the last boundary and collide with it. */
|
||||
overflowSplit: number | null;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
row: number;
|
||||
column: number;
|
||||
count: number | null;
|
||||
}
|
||||
@@ -41,9 +41,6 @@ export default function ChartWrapper({
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
customLegend,
|
||||
legendLabels,
|
||||
contentFooter,
|
||||
'data-testid': testId,
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -64,10 +61,6 @@ export default function ChartWrapper({
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// Charts whose legend does not list uPlot series supply their own.
|
||||
if (customLegend) {
|
||||
return customLegend(averageLegendWidth);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
@@ -76,7 +69,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend, customLegend],
|
||||
[config, legendConfig.position, showLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -107,8 +100,6 @@ export default function ChartWrapper({
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
seriesLabels={legendLabels}
|
||||
contentFooter={contentFooter}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import { useCallback, 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';
|
||||
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
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';
|
||||
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';
|
||||
|
||||
/** Vertical space the colour bar takes out of the container. */
|
||||
const COLOR_BAR_HEIGHT = 28;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
|
||||
* ~9,600 cells, far past what per-cell DOM carries.
|
||||
*/
|
||||
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
const {
|
||||
id,
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
axisScale = HeatmapAxisScale.Log,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
showVisualMap = true,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
dimOnHover = true,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
pinKey,
|
||||
seriesColor,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onCellClick,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
} = props;
|
||||
|
||||
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
|
||||
const hoveredCellRef = useRef<HeatmapCell | 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 colors = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
|
||||
[props.colors],
|
||||
);
|
||||
|
||||
// The opacity fill no longer follows a group colour: with several groups enabled
|
||||
// at once there is no single one to follow.
|
||||
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
|
||||
|
||||
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
|
||||
// read against the panel.
|
||||
const extremeColor = resolveExtremeColor({
|
||||
options: colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
});
|
||||
|
||||
const {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = useHeatmapGroupLegend({ groups });
|
||||
|
||||
const grid = useMemo(
|
||||
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
|
||||
[buckets, step, series, visibleGroups],
|
||||
);
|
||||
|
||||
const yAxis = useMemo(
|
||||
() => resolveHeatmapYAxis(grid.bounds, axisScale),
|
||||
[grid.bounds, axisScale],
|
||||
);
|
||||
|
||||
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
hasGrid
|
||||
? prepareHeatmapChartData(grid, yAxis.rows.length)
|
||||
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
|
||||
[grid, yAxis.rows.length, hasGrid],
|
||||
);
|
||||
|
||||
const colorResolver = useMemo(
|
||||
() =>
|
||||
createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(colors, grid.counts),
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
}),
|
||||
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
|
||||
);
|
||||
|
||||
// Stable: the renderer captures it at config-build time, so a new identity would
|
||||
// recreate the plot on every hover.
|
||||
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
|
||||
hoveredCellRef.current = cell;
|
||||
setHoveredCell(cell);
|
||||
}, []);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange: handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
],
|
||||
);
|
||||
|
||||
// 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) => ({
|
||||
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
|
||||
// handling is identical across charts.
|
||||
seriesIndex: index + 1,
|
||||
label: group,
|
||||
color: isPaletteMode
|
||||
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
|
||||
: extremeColor,
|
||||
show: visibleGroups.includes(group),
|
||||
})),
|
||||
[
|
||||
groups,
|
||||
visibleGroups,
|
||||
isPaletteMode,
|
||||
colorResolver,
|
||||
groupPeaks,
|
||||
extremeColor,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<HeatmapTooltip
|
||||
{...args}
|
||||
id={id}
|
||||
yAxis={yAxis}
|
||||
step={grid.step}
|
||||
series={series}
|
||||
visibleGroups={visibleGroups}
|
||||
groupColor={extremeColor}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
timezone={timezone}
|
||||
canPinTooltip={canPinTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
id,
|
||||
yAxis,
|
||||
grid.step,
|
||||
series,
|
||||
visibleGroups,
|
||||
extremeColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((clickData: ChartClickData): void => {
|
||||
if (hoveredCellRef.current) {
|
||||
onCellClickRef.current?.(hoveredCellRef.current, clickData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
),
|
||||
[
|
||||
legendItems,
|
||||
legendPosition,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
],
|
||||
);
|
||||
|
||||
const visualMap = useMemo(() => {
|
||||
if (!showVisualMap || !hasGrid) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ColorBar
|
||||
label="count"
|
||||
ramp={colorResolver.ramp}
|
||||
minLabel={colorResolver.domain.min.toLocaleString()}
|
||||
maxLabel={colorResolver.domain.max.toLocaleString()}
|
||||
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
|
||||
/>
|
||||
);
|
||||
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={hasGroupLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={groups}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
pinKey={pinKey}
|
||||
onClick={onCellClick ? handleClick : undefined}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
contentFooter={visualMap}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import type React from 'react';
|
||||
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,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import Heatmap from '../Heatmap';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [128, 256, 1024];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
|
||||
const SERIES: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'service.name=cart',
|
||||
points: [
|
||||
{ timestamp: 1000, counts: [1, 4, 7, 10] },
|
||||
{ timestamp: 1060, counts: [2, null, 8, 11] },
|
||||
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'service.name=checkout',
|
||||
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
function renderHeatmap(
|
||||
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Heatmap
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
step={STEP}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="heatmap"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Heatmap', () => {
|
||||
it('renders the plot container', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the colour bar with the resolved count domain', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts the colour bar against the plot, with the legend after it', () => {
|
||||
renderHeatmap();
|
||||
|
||||
const bar = screen.getByTestId('color-bar');
|
||||
const legend = screen.getByText('service.name=cart').closest('.legend-item');
|
||||
// The bar is the scale key for the grid, so it reads before the controls.
|
||||
expect(
|
||||
bar.compareDocumentPosition(legend as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the colour bar inside the chart column, not below the legend', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('color-bar').closest('.chart-layout__content'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the colour bar when the visual map is off', () => {
|
||||
renderHeatmap({ showVisualMap: false });
|
||||
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the colour bar with an explicit clamp instead of the data range', () => {
|
||||
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when the metric has no buckets', () => {
|
||||
renderHeatmap({ buckets: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when no columns came back', () => {
|
||||
renderHeatmap({ series: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heatmap group legend', () => {
|
||||
const CART = 'service.name=cart';
|
||||
const CHECKOUT = 'service.name=checkout';
|
||||
|
||||
function legendItem(label: string): HTMLElement {
|
||||
const item = screen.getByText(label).closest('.legend-item');
|
||||
if (!item) {
|
||||
throw new Error(`no legend item for ${label}`);
|
||||
}
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
function marker(label: string): HTMLElement {
|
||||
const element = legendItem(label).querySelector<HTMLElement>(
|
||||
'[data-is-legend-marker]',
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error(`no marker for ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
it('lists the groups, with no combined-view entry', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByText(CART)).toBeInTheDocument();
|
||||
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables every group to begin with', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(marker(CHECKOUT));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
/** 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({
|
||||
options: DEFAULT_HEATMAP_COLORS,
|
||||
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
|
||||
isDarkMode: true,
|
||||
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
|
||||
}).ramp.map((color) => color.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);
|
||||
});
|
||||
|
||||
it('gives every marker the solid fill in opacity mode', () => {
|
||||
renderHeatmap({
|
||||
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
|
||||
});
|
||||
|
||||
// A partially transparent marker is hard to read against the panel.
|
||||
expect(marker(CART).style.borderColor).toBe(
|
||||
marker(CHECKOUT).style.borderColor,
|
||||
);
|
||||
expect(marker(CART).style.borderColor).not.toBe('');
|
||||
});
|
||||
|
||||
it('hides the legend when there is only one group to choose from', () => {
|
||||
renderHeatmap({ series: [SERIES[0]] });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the legend when asked', () => {
|
||||
renderHeatmap({ showLegend: false });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
|
||||
|
||||
const GROUPS = ['cart', 'checkout', 'payments'];
|
||||
|
||||
/** Mimics a click on an item's label, as the shared Legend renders it. */
|
||||
function labelClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
|
||||
const label = document.createElement('span');
|
||||
wrapper.appendChild(label);
|
||||
return { target: label } as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Mimics a click on the item's marker circle. */
|
||||
function markerClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
|
||||
const marker = document.createElement('div');
|
||||
marker.dataset.isLegendMarker = 'true';
|
||||
wrapper.appendChild(marker);
|
||||
return { target: marker } as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function render(
|
||||
groups: string[] = GROUPS,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
|
||||
> {
|
||||
return renderHook(() => useHeatmapGroupLegend({ groups }));
|
||||
}
|
||||
|
||||
describe('useHeatmapGroupLegend', () => {
|
||||
it('enables every group to begin with', () => {
|
||||
const { result } = render();
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('moves the isolation when a different label is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
act(() => result.current.onLegendClick(labelClick(3)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['payments']);
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
|
||||
});
|
||||
|
||||
it('re-includes a group when its marker is clicked again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('excludes more than one group', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(1)));
|
||||
act(() => result.current.onLegendClick(markerClick(3)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('drops the isolation when a marker is clicked, so the label can isolate again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
// Re-including cart by marker leaves it enabled but no longer isolated.
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart']);
|
||||
});
|
||||
|
||||
it('allows every group to be excluded, as the other legends do', () => {
|
||||
const { result } = render();
|
||||
|
||||
GROUPS.forEach((_, index) =>
|
||||
act(() => result.current.onLegendClick(markerClick(index + 1))),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores clicks that miss an entry', () => {
|
||||
const { result } = render();
|
||||
const stray = {
|
||||
target: document.createElement('div'),
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
|
||||
act(() => result.current.onLegendClick(stray));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('forgets a hidden group that left the result', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ groups }) => useHeatmapGroupLegend({ groups }),
|
||||
{ initialProps: { groups: GROUPS } },
|
||||
);
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(3)));
|
||||
rerender({ groups: ['cart', 'checkout'] });
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
|
||||
});
|
||||
|
||||
it('tracks the hovered entry for the legend"s focus highlight', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendMouseMove(labelClick(2)));
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
|
||||
|
||||
const GRID: HeatmapGrid = {
|
||||
bounds: [128, 256, 1024],
|
||||
timestamps: [1000, 1060, 1120],
|
||||
step: 60,
|
||||
counts: [
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
[0, 0, 0],
|
||||
],
|
||||
};
|
||||
|
||||
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
|
||||
|
||||
/** Tall enough that no tick needs thinning. */
|
||||
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
|
||||
|
||||
function readSplits(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
plot: uPlot,
|
||||
): number[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
|
||||
}
|
||||
|
||||
function readLabels(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
splits: number[],
|
||||
): string[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
|
||||
{} as uPlot,
|
||||
splits,
|
||||
);
|
||||
}
|
||||
|
||||
function readRange(scale?: uPlot.Scale): [number, number] {
|
||||
const range = scale?.range as (
|
||||
u: uPlot,
|
||||
min: number,
|
||||
max: number,
|
||||
) => [number, number];
|
||||
return range({} as uPlot, 0, 0);
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
|
||||
): ReturnType<typeof buildHeatmapConfig> {
|
||||
return buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('prepareHeatmapChartData', () => {
|
||||
it('puts timestamps first and one series per bucket row', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
expect(data[0]).toStrictEqual(GRID.timestamps);
|
||||
expect(data[1]).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves null cells rather than zeroing them', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data[2]).toStrictEqual([4, null, 6]);
|
||||
});
|
||||
|
||||
it('pads short rows so every uPlot data array is the same length', () => {
|
||||
const data = prepareHeatmapChartData(
|
||||
{ ...GRID, counts: [[1]] },
|
||||
Y_AXIS.rows.length,
|
||||
);
|
||||
|
||||
expect(data[1]).toStrictEqual([1, null, null]);
|
||||
});
|
||||
|
||||
it('pads missing rows up to the resolved row count', () => {
|
||||
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
|
||||
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[2]).toStrictEqual([null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeatmapConfig', () => {
|
||||
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
|
||||
const config = buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
}).getConfig();
|
||||
|
||||
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
});
|
||||
|
||||
it('draws no paths or points per series — the renderer paints the cells', () => {
|
||||
const [, firstRow] = buildConfig().getConfig().series ?? [];
|
||||
|
||||
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
|
||||
expect((firstRow as uPlot.Series).points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('labels series by bucket range, including the open-ended rows', () => {
|
||||
const labels = (buildConfig().getConfig().series ?? [])
|
||||
.slice(1)
|
||||
.map((series) => series.label);
|
||||
|
||||
expect(labels[0]).toContain('≤');
|
||||
expect(labels[labels.length - 1]).toContain('>');
|
||||
});
|
||||
|
||||
it('spans the x scale to the end of the last column, not its start', () => {
|
||||
const { x } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([1000, 1180]);
|
||||
});
|
||||
|
||||
it('prefers the query window over the grid extent', () => {
|
||||
const { x } =
|
||||
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
|
||||
{};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([900, 1500]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
|
||||
const { y } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(y?.auto).toBe(false);
|
||||
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
|
||||
});
|
||||
|
||||
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
|
||||
const splits = readSplits(buildConfig(), TALL_PLOT);
|
||||
|
||||
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
|
||||
});
|
||||
|
||||
it('labels the overflow edge as infinite and the rest by bucket value', () => {
|
||||
const config = buildConfig();
|
||||
const labels = readLabels(config, readSplits(config, TALL_PLOT));
|
||||
|
||||
expect(labels[0]).toBe('128');
|
||||
expect(labels[labels.length - 1]).toBe('∞');
|
||||
});
|
||||
|
||||
it('thins the tick set when the panel is too short to label every boundary', () => {
|
||||
const config = buildConfig();
|
||||
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
|
||||
|
||||
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
|
||||
// The infinite edge is the one label that must never be dropped.
|
||||
expect(readLabels(config, splits).at(-1)).toBe('∞');
|
||||
});
|
||||
|
||||
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
|
||||
const config = buildConfig().getConfig();
|
||||
|
||||
expect(config.cursor?.focus?.prox).toBe(-1);
|
||||
expect(config.cursor?.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
|
||||
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
|
||||
});
|
||||
|
||||
it('registers the renderer hooks', () => {
|
||||
const { hooks } = buildConfig().getConfig();
|
||||
|
||||
expect(hooks?.init).toHaveLength(1);
|
||||
expect(hooks?.draw).toHaveLength(1);
|
||||
expect(hooks?.setCursor).toHaveLength(1);
|
||||
expect(hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface UseHeatmapGroupLegendResult {
|
||||
/** Groups currently enabled. The grid sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
/** The shared Legend tags each item and delegates interaction to the container. */
|
||||
function getLegendIndex(event: MouseEvent<HTMLDivElement>): number | null {
|
||||
const element = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = element?.dataset.legendItemId;
|
||||
return id === undefined ? null : Number(id);
|
||||
}
|
||||
|
||||
function isMarkerClick(event: MouseEvent<HTMLDivElement>): boolean {
|
||||
return Boolean((event.target as HTMLElement).dataset.isLegendMarker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group visibility for the heatmap legend, matching every other legend in the
|
||||
* product: the label isolates a group, the marker excludes one, and everything is
|
||||
* enabled to begin with. Counts are additive, so whatever is enabled is summed
|
||||
* client-side and needs no extra request.
|
||||
*
|
||||
* Visibility only. Marker colour is resolved by the caller, which owns the colour
|
||||
* ramp — and that ramp depends on which groups this hook has enabled.
|
||||
*/
|
||||
export function useHeatmapGroupLegend({
|
||||
groups,
|
||||
}: {
|
||||
groups: string[];
|
||||
}): UseHeatmapGroupLegendResult {
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const isolatedRef = useRef<string | null>(null);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((group) => !hidden.has(group)),
|
||||
[groups, hidden],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(event);
|
||||
const group = index === null ? undefined : groups[index - 1];
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkerClick(event)) {
|
||||
isolatedRef.current = null;
|
||||
setHidden((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(group)) {
|
||||
next.delete(group);
|
||||
} else {
|
||||
next.add(group);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Label click isolates; clicking the isolated group again restores all.
|
||||
const isReset = isolatedRef.current === group;
|
||||
isolatedRef.current = isReset ? null : group;
|
||||
setHidden(
|
||||
isReset ? new Set() : new Set(groups.filter((entry) => entry !== group)),
|
||||
);
|
||||
},
|
||||
[groups],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
setFocusedSeriesIndex(getLegendIndex(event));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => {
|
||||
setFocusedSeriesIndex(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
createHeatmapHooks,
|
||||
HeatmapRenderOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
|
||||
import { HeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Minimum gap between y tick labels, in CSS pixels. */
|
||||
const MIN_Y_TICK_GAP_PX = 18;
|
||||
|
||||
/** Label for the edge above the overflow row. */
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
|
||||
/**
|
||||
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
|
||||
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
|
||||
*
|
||||
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
|
||||
* the response carried duplicate boundaries — since uPlot requires equal lengths.
|
||||
*/
|
||||
export function prepareHeatmapChartData(
|
||||
grid: HeatmapGrid,
|
||||
rowCount: number,
|
||||
): uPlot.AlignedData {
|
||||
const columnCount = grid.timestamps.length;
|
||||
const rows = Array.from({ length: rowCount }, (_, row) => {
|
||||
const counts = grid.counts[row] ?? [];
|
||||
return Array.from({ length: columnCount }, (_, column) =>
|
||||
counts[column] === undefined ? null : counts[column],
|
||||
);
|
||||
});
|
||||
|
||||
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface BuildHeatmapConfigArgs extends Omit<
|
||||
HeatmapRenderOptions,
|
||||
'step'
|
||||
> {
|
||||
id: string;
|
||||
grid: HeatmapGrid;
|
||||
/** Unit of the bucket boundaries; counts are never formatted with it. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Query window, in seconds. Falls back to the grid's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
}
|
||||
|
||||
export function buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
|
||||
|
||||
// uPlot's focus picks the series closest in value space, meaningless when the
|
||||
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
|
||||
// that call off uPlot's full-redraw path.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
|
||||
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
|
||||
const xRange: [number, number] = [
|
||||
minTimeScale ?? grid.timestamps[0] ?? 0,
|
||||
maxTimeScale ?? lastTimestamp + grid.step,
|
||||
];
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
range: (): [number, number] => xRange,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [yAxis.min, yAxis.max],
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
|
||||
});
|
||||
|
||||
// Ticks sit on row edges, so the overflow row is the band between the last
|
||||
// boundary and `∞`. A centre label would sit half a row from the boundary tick
|
||||
// and collide with it.
|
||||
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
|
||||
const hasOverflowTick =
|
||||
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
|
||||
const axisSplits = hasOverflowTick
|
||||
? [...yAxis.splits, yAxis.overflowSplit as number]
|
||||
: yAxis.splits;
|
||||
|
||||
// From the boundaries themselves, not by inverting the transform:
|
||||
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
|
||||
const splitLabels = new Map<number, string>();
|
||||
yAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
|
||||
});
|
||||
if (hasOverflowTick) {
|
||||
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
// Thinned to whatever fits: a histogram can carry more boundaries than the
|
||||
// panel has room to label.
|
||||
splits: (self): number[] =>
|
||||
decimateAxisSplits({
|
||||
splits: axisSplits,
|
||||
min: yAxis.min,
|
||||
max: yAxis.max,
|
||||
plotHeight: self.bbox.height / uPlot.pxRatio,
|
||||
minGapPx: MIN_Y_TICK_GAP_PX,
|
||||
}),
|
||||
values: (_, splits): string[] =>
|
||||
splits.map(
|
||||
(split) =>
|
||||
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
|
||||
),
|
||||
});
|
||||
|
||||
yAxis.rows.forEach((row) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// Nothing is stroked per series; the draw hook paints the grid.
|
||||
drawStyle: DrawStyle.Line,
|
||||
pathBuilder: (): null => null,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
label: formatRowLabel(row, formatBucketValue),
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
const hooks = createHeatmapHooks({
|
||||
yAxis,
|
||||
step: grid.step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
|
||||
// Order matters — see the HeatmapHooks doc comment.
|
||||
builder.addHook('init', hooks.init);
|
||||
builder.addHook('draw', hooks.draw);
|
||||
builder.addHook('setCursor', hooks.setCursor);
|
||||
builder.addHook('destroy', hooks.destroy);
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -9,12 +9,6 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -39,13 +33,6 @@ interface BaseChartProps {
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
/** Replaces the config-driven legend, for charts whose legend lists something
|
||||
* other than uPlot series — heatmap groups, where the series are bucket rows. */
|
||||
customLegend?: (averageLegendWidth: number) => React.ReactNode;
|
||||
/** Measured against for the chart/legend split. Pair with `customLegend`. */
|
||||
legendLabels?: string[];
|
||||
/** Rendered under the plot but above the legend, inside the chart column. */
|
||||
contentFooter?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
@@ -87,60 +74,6 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data arrives as the query response carries it — bucket bounds plus one series per
|
||||
* group — and the chart pivots and sums it, so no caller has to get the transpose
|
||||
* or the combined view right. It builds its own `UPlotConfigBuilder` too, since the
|
||||
* y axis *is* the bucket axis and `buckets` fully determines it.
|
||||
*
|
||||
* `buckets`, `series` and `colors` must be referentially stable: a new identity
|
||||
* rebuilds the config, which recreates the plot.
|
||||
*/
|
||||
export interface HeatmapChartProps {
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 rows. */
|
||||
buckets: number[];
|
||||
/** The *effective* step the server used (`meta.stepIntervals[queryName]`), not
|
||||
* the requested one. Cannot be inferred: the last column has no successor. */
|
||||
step: number;
|
||||
/** One entry per group; a query without grouping yields one series. */
|
||||
series: HeatmapSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
|
||||
colors?: Partial<HeatmapColorOptions>;
|
||||
/** Default log. */
|
||||
axisScale?: HeatmapAxisScale;
|
||||
/** Unit of the bucket boundaries; counts are always plain numbers. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Colour bar below the grid. Default true. */
|
||||
showVisualMap?: boolean;
|
||||
/** Default true; hidden anyway when there is only one group. Every group starts
|
||||
* enabled — the label isolates one, the marker excludes one. */
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
pinKey?: string;
|
||||
/** Overrides the opacity-mode fill, which otherwise follows the selected
|
||||
* group's legend colour so the grid matches the swatch that was clicked. */
|
||||
seriesColor?: string;
|
||||
/** Query window, in seconds. Falls back to the data's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
onCellClick?: (cell: HeatmapCell, clickData: ChartClickData) => void;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -16,31 +16,20 @@ export interface ChartLayoutProps {
|
||||
averageLegendWidth: number;
|
||||
}) => React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
/**
|
||||
* Rendered directly under the plot, inside the chart column — so it stays next to
|
||||
* the axis with the legend below it, and beside a RIGHT legend rather than under
|
||||
* it. `layoutChildren` sits below everything instead.
|
||||
*/
|
||||
contentFooter?: React.ReactNode;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
legendConfig: LegendConfig;
|
||||
config: UPlotConfigBuilder;
|
||||
/** Defaults to the chart's series labels. Pass them when the legend lists
|
||||
* something else, or the split is measured against the wrong text. */
|
||||
seriesLabels?: string[];
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
contentFooter,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
config,
|
||||
seriesLabels,
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
@@ -53,20 +42,19 @@ export default function ChartLayout({
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const resolvedLabels =
|
||||
seriesLabels ??
|
||||
Object.values(config.getLegendItems())
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
return calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
seriesLabels: resolvedLabels,
|
||||
seriesLabels,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -84,7 +72,6 @@ export default function ChartLayout({
|
||||
chartHeight: chartDimensions.height,
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
{contentFooter}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
|
||||
@@ -5,10 +5,11 @@ import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
@@ -67,7 +68,7 @@ function ConfigPane({
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
// onChangeSpec path the sections use, so there's a single editing surface.
|
||||
|
||||
@@ -5,8 +5,18 @@ import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -34,6 +44,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
mode: 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
@@ -27,17 +27,17 @@ export function usePanelTypeSelectItems({
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
label: displayName,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
value: kind,
|
||||
label: displayName,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ import VisualizationSection from '../VisualizationSection';
|
||||
// the test doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
mode: 'query',
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
})),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
|
||||
@@ -26,3 +26,16 @@
|
||||
background: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
// The static editor's preview: the panel card the grid shows, minus actions.
|
||||
.staticPreviewSurface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
margin: 12px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
|
||||
import layoutStorage from '../layoutStorage';
|
||||
|
||||
import styles from '../PanelEditor.module.scss';
|
||||
|
||||
/** A resizable pane's bounds, in the percentage strings `ResizablePanel` takes. */
|
||||
interface PaneSize {
|
||||
minSize: string;
|
||||
maxSize: string;
|
||||
defaultSize: string;
|
||||
}
|
||||
|
||||
/** How the left column divides between the preview and the editor pane. */
|
||||
export interface PaneSplit {
|
||||
preview: PaneSize;
|
||||
editor: PaneSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical split per authoring mode. The query builder is a compact form, so the
|
||||
* preview keeps the room; a static kind's editor pane is the surface being worked
|
||||
* in, so it gets more and can grow further.
|
||||
*/
|
||||
export const PANE_SPLIT = {
|
||||
query: {
|
||||
preview: { minSize: '55%', maxSize: '65%', defaultSize: '60%' },
|
||||
editor: { minSize: '35%', maxSize: '45%', defaultSize: '40%' },
|
||||
},
|
||||
static: {
|
||||
preview: { minSize: '40%', maxSize: '65%', defaultSize: '55%' },
|
||||
editor: { minSize: '35%', maxSize: '60%', defaultSize: '45%' },
|
||||
},
|
||||
} as const satisfies Record<string, PaneSplit>;
|
||||
|
||||
interface PanelEditorLayoutProps {
|
||||
/** Save/close chrome — its affordances differ per authoring mode. */
|
||||
header: ReactNode;
|
||||
/** Upper-left: what the panel will look like once saved. */
|
||||
preview: ReactNode;
|
||||
/** Lower-left: the kind's `EditorPane` — the query builder, or a static kind's own. */
|
||||
editor: ReactNode;
|
||||
/** Right column: the kind's config sections. */
|
||||
config: ReactNode;
|
||||
split: PaneSplit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel editor's frame: header, the resizable three-pane arrangement, and the
|
||||
* persistence of what the user drags. Owned once so both authoring modes cannot
|
||||
* drift apart on pane bounds or share a layout id by accident — they differ only in
|
||||
* `split` and in what fills the slots.
|
||||
*/
|
||||
function PanelEditorLayout({
|
||||
header,
|
||||
preview,
|
||||
editor,
|
||||
config,
|
||||
split,
|
||||
}: PanelEditorLayoutProps): JSX.Element {
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
{header}
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel {...split.preview}>{preview}</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel {...split.editor}>{editor}</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
>
|
||||
{config}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorLayout;
|
||||
@@ -21,20 +21,15 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
|
||||
panelKind: PanelKind;
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The panel's current signal; selects per-signal query-builder field rules. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
@@ -55,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelKind,
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
@@ -65,10 +60,10 @@ function PanelEditorQueryBuilder({
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
|
||||
// builder offers for this kind comes from the kind's own declaration.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelKind === 'signoz/ListPanel';
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -99,12 +94,12 @@ function PanelEditorQueryBuilder({
|
||||
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
|
||||
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => getHiddenQueryBuilderFields(panelKind, signal),
|
||||
[panelKind, signal],
|
||||
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
|
||||
[panelDefinition.queryBuilderFields, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -151,7 +146,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
|
||||
|
||||
/**
|
||||
* The default query-kind editor pane: the query-builder tabs with no extras. A
|
||||
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
|
||||
*/
|
||||
function QueryBuilderEditorPane({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
}: QueryEditorPaneProps): JSX.Element {
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryBuilderEditorPane;
|
||||
@@ -4,6 +4,9 @@ import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
|
||||
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
|
||||
@@ -48,7 +51,7 @@ function renderBuilder(
|
||||
): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind as never}
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
|
||||
@@ -6,7 +6,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
@@ -20,8 +20,8 @@ import styles from './PreviewPane.module.scss';
|
||||
interface PreviewPaneProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
data: PanelQueryData;
|
||||
/** Any fetch in flight — drives the header spinner and the body's loading state. */
|
||||
isFetching: boolean;
|
||||
@@ -107,7 +107,7 @@ function PreviewPane({
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
PANE_SPLIT,
|
||||
} from './PanelEditorLayout/PanelEditorLayout';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
// Portal them to the document body; the query-builder filters honor this via
|
||||
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
|
||||
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
|
||||
const getBodyPopupContainer = (): HTMLElement => document.body;
|
||||
|
||||
interface QueryEditorBodyProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** The dashboard can be edited (unlocked + permission); gates Save. */
|
||||
isEditable: boolean;
|
||||
/** Why Save is disabled (locked / no permission); '' when editable. */
|
||||
editDisabledReason: string;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
onSaved: () => void;
|
||||
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
|
||||
draftApi: PanelEditorDraftApi;
|
||||
/** The draft kind's definition, narrowed by the shell's fork. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query-kind editor body: a resizable split with the live preview + the
|
||||
* kind's editor pane on the left and the config pane on the right. Draft and
|
||||
* kind-switch state live in the shell; this body owns the query session and the
|
||||
* save round-trip.
|
||||
*/
|
||||
function QueryEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
onClose,
|
||||
onSaved,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
}: QueryEditorBodyProps): JSX.Element {
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
draftApi,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// Unsaved-edits flag driving the discard confirmation on close (Save is always
|
||||
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
|
||||
// commits the seed into the draft on open, which would falsely dirty an untouched
|
||||
// query-less new panel.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
// field-key lookup is typed.
|
||||
const listSignal =
|
||||
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
|
||||
TelemetrytypesSignalDTO.logs;
|
||||
|
||||
// Swap the List panel's columns to the new signal's defaults on signal change
|
||||
// (V1 had a per-signal field list; V2 has one `selectFields`).
|
||||
useSwitchColumnsOnSignalChange({
|
||||
enabled: isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's columns from the query's resolved signal (not the kind's
|
||||
// default logs signal) so a traces-List export gets traces columns, not logs.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
const intervals = getExecStats(data.response)?.stepIntervals;
|
||||
const values = intervals ? Object.values(intervals) : [];
|
||||
return values.length ? Math.min(...values) : undefined;
|
||||
}, [data.response]);
|
||||
|
||||
const onSwitchToView = useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved', {
|
||||
position: 'top-center',
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
showErrorModal(err);
|
||||
}
|
||||
}, [
|
||||
isEditable,
|
||||
save,
|
||||
buildSaveSpec,
|
||||
draft.spec,
|
||||
setScrollTargetId,
|
||||
onSaved,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
const switchToViewMode = useCallback((): void => {
|
||||
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
onSwitchToView();
|
||||
}, [onSwitchToView]);
|
||||
|
||||
return (
|
||||
<PanelEditorLayout
|
||||
split={PANE_SPLIT.query}
|
||||
header={
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={switchToViewMode}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
}
|
||||
editor={
|
||||
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
|
||||
<EditorPane
|
||||
panelDefinition={panelDefinition}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
}
|
||||
config={
|
||||
<ConfigPane
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryEditorBody;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
PANE_SPLIT,
|
||||
} from './PanelEditorLayout/PanelEditorLayout';
|
||||
import type { PanelEditorContainerProps } from './index';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
|
||||
interface StaticEditorBodyProps extends PanelEditorContainerProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor body for a kind that renders from its own plugin spec: the kind's
|
||||
* editor pane under a live preview of the draft, the config pane on the right.
|
||||
* No query session, no builder seeding, no staged-run — the preview re-renders
|
||||
* from the draft spec on every edit.
|
||||
*/
|
||||
function StaticEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
onClose,
|
||||
onSaved,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
}: StaticEditorBodyProps): JSX.Element {
|
||||
const { draft, spec, setSpec, isSpecDirty } = draftApi;
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// `queries: []` is the only shape the API accepts for a static kind.
|
||||
const savedPanelId = await save({ ...draft.spec, queries: [] });
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved', {
|
||||
position: 'top-center',
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
showErrorModal(err);
|
||||
}
|
||||
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
|
||||
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
return (
|
||||
<PanelEditorLayout
|
||||
split={PANE_SPLIT.static}
|
||||
header={
|
||||
<Header
|
||||
isDirty={isSpecDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={false}
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<div className={styles.staticPreviewSurface}>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
data={EMPTY_PANEL_QUERY_DATA}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
hideActions
|
||||
/>
|
||||
<StaticPanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
|
||||
config={
|
||||
<ConfigPane
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
legendSeries={[]}
|
||||
tableColumns={[]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default StaticEditorBody;
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
@@ -232,7 +233,12 @@ describe('PanelEditorContainer composition', () => {
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
expect.objectContaining({
|
||||
panelDefinition: expect.objectContaining({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
mode: 'query',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -256,7 +262,7 @@ describe('PanelEditorContainer composition', () => {
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -18,6 +18,8 @@ import appStore from 'store';
|
||||
|
||||
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
|
||||
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
@@ -83,7 +85,7 @@ function EditorRoute(): JSX.Element {
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
|
||||
@@ -19,6 +19,10 @@ jest.mock('lib/query/panelQuery', () => ({
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
// Real predicate: these specs use real (query) kinds and the static path is
|
||||
// exercised through its own cases below.
|
||||
isQuerylessPanelKind: jest.requireActual('../../../Panels/capabilities')
|
||||
.isQuerylessPanelKind,
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import {
|
||||
type LegendSeries,
|
||||
resolvePieLegendSeries,
|
||||
resolveTimeSeriesLegendSeries,
|
||||
} from '../utils/legendSeries';
|
||||
|
||||
/**
|
||||
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
|
||||
* legend-colors control can key overrides by the exact labels the chart draws. Only the
|
||||
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
|
||||
* Series from its flat series); every other kind returns none.
|
||||
* legend-colors control can key overrides by the exact labels the chart draws, using
|
||||
* the resolver the kind declares as its `colors` control.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
data: PanelQueryData,
|
||||
): LegendSeries[] {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const kind = panel.spec.plugin.kind;
|
||||
|
||||
return useMemo(() => {
|
||||
switch (panel.spec.plugin.kind) {
|
||||
case 'signoz/PieChartPanel':
|
||||
return resolvePieLegendSeries(data, isDarkMode);
|
||||
case 'signoz/TimeSeriesPanel':
|
||||
case 'signoz/BarChartPanel':
|
||||
case 'signoz/HistogramPanel':
|
||||
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
|
||||
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
|
||||
return resolve
|
||||
? resolve({ queries: panel.spec.queries, data, isDarkMode })
|
||||
: [];
|
||||
}, [kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,19 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getPanelDefinition,
|
||||
isPanelKindSupported,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
usePanelQuery,
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { PanelEditorDraftApi } from '../types';
|
||||
import { usePanelEditorDraft } from './usePanelEditorDraft';
|
||||
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
|
||||
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -38,6 +33,12 @@ interface UsePanelEditSessionArgs {
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
|
||||
seedQuerySignal?: boolean;
|
||||
/**
|
||||
* Externally-owned draft. The editor shell hoists it above its mode fork so a
|
||||
* kind switch across modes survives the branch swap; hosts without a fork (the
|
||||
* View modal, until it forks) omit it and the session owns the draft.
|
||||
*/
|
||||
draftApi?: PanelEditorDraftApi;
|
||||
}
|
||||
|
||||
export interface UsePanelEditSessionReturn {
|
||||
@@ -50,7 +51,7 @@ export interface UsePanelEditSessionReturn {
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
@@ -62,8 +63,6 @@ export interface UsePanelEditSessionReturn {
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,14 +79,17 @@ export function usePanelEditSession({
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
draftApi,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
|
||||
panel,
|
||||
savedPanel,
|
||||
);
|
||||
// Called unconditionally (hooks rules); unused when a hoisted draft is passed in.
|
||||
const internalDraftApi = usePanelEditorDraft(panel, savedPanel);
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
draftApi ?? internalDraftApi;
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
// Hosts fork on `definition.mode` before mounting this session (the editor and
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
@@ -109,12 +111,6 @@ export function usePanelEditSession({
|
||||
savedQueries: savedPanel?.spec.queries,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
@@ -128,6 +124,5 @@ export function usePanelEditSession({
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import type {
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
isQuerylessPanelKind,
|
||||
resolveQueryType,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -128,11 +131,25 @@ export function usePanelTypeSwitch({
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
// Revisit → restore the stash verbatim (the reversibility path). A static
|
||||
// kind's stash carries `queries: []` and its builder query is untouched —
|
||||
// there is no builder to re-seed for it.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
if (!isQuerylessPanelKind(newKind)) {
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit to a static kind → fresh spec from its sections, queries
|
||||
// emptied (the API accepts nothing else), and the query builder left as-is:
|
||||
// the stash above keeps the old kind's query for the return trip.
|
||||
if (isQuerylessPanelKind(newKind)) {
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,13 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
|
||||
import styles from './PanelEditor.module.scss';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
// Portal them to the document body; the query-builder filters honor this via
|
||||
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
|
||||
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
|
||||
const getBodyPopupContainer = (): HTMLElement => document.body;
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
export interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -74,293 +31,42 @@ interface PanelEditorContainerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 panel editor page body: a resizable split with the live preview + query
|
||||
* builder on the left and the config pane on the right. Owns the draft state and
|
||||
* the save round-trip.
|
||||
* V2 panel editor page shell. Owns exactly the state that must survive a switch
|
||||
* between authoring modes — the draft and the kind-switch cache — and forks on
|
||||
* the draft kind's `mode`: query kinds get the session-backed body, static kinds
|
||||
* an editor pane over a live preview with no query machinery at all.
|
||||
*/
|
||||
function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
editDisabledReason,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PanelEditorContainerProps): JSX.Element {
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
panelDefinition,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = query;
|
||||
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
const { panel, savedPanel } = props;
|
||||
const draftApi = usePanelEditorDraft(panel, savedPanel);
|
||||
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
const panelKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// Unsaved-edits flag driving the discard confirmation on close (Save is always
|
||||
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
|
||||
// commits the seed into the draft on open, which would falsely dirty an untouched
|
||||
// query-less new panel.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
// field-key lookup is typed.
|
||||
const listSignal =
|
||||
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
|
||||
TelemetrytypesSignalDTO.logs;
|
||||
|
||||
// Swap the List panel's columns to the new signal's defaults on signal change
|
||||
// (V1 had a per-signal field list; V2 has one `selectFields`).
|
||||
useSwitchColumnsOnSignalChange({
|
||||
enabled: isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's columns from the query's resolved signal (not the kind's
|
||||
// default logs signal) so a traces-List export gets traces columns, not logs.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
const intervals = getExecStats(data.response)?.stepIntervals;
|
||||
const values = intervals ? Object.values(intervals) : [];
|
||||
return values.length ? Math.min(...values) : undefined;
|
||||
}, [data.response]);
|
||||
|
||||
const onSwitchToView = useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved', {
|
||||
position: 'top-center',
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
showErrorModal(err);
|
||||
}
|
||||
}, [
|
||||
isEditable,
|
||||
save,
|
||||
buildSaveSpec,
|
||||
draft.spec,
|
||||
setScrollTargetId,
|
||||
onSaved,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
const switchToViewMode = useCallback((): void => {
|
||||
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
onSwitchToView();
|
||||
}, [onSwitchToView]);
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticEditorBody
|
||||
{...props}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
readOnly={!isEditable}
|
||||
readOnlyReason={editDisabledReason}
|
||||
onSave={onSave}
|
||||
onSwitchToView={switchToViewMode}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
|
||||
{panelDefinition && (
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
>
|
||||
<ConfigPane
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<QueryEditorBody
|
||||
{...props}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryTypes,
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
@@ -107,7 +109,7 @@ const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
|
||||
describe('panel capabilities guard', () => {
|
||||
describe('query capabilities', () => {
|
||||
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
|
||||
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
|
||||
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
|
||||
EXPECTED_QUERY_CAPABILITIES[kind],
|
||||
);
|
||||
});
|
||||
@@ -149,7 +151,8 @@ describe('panel capabilities guard', () => {
|
||||
});
|
||||
|
||||
it('carries an inert query shape, so a stray request can do no harm', () => {
|
||||
const { queryCapabilities } = getPanelDefinition(unknownKind);
|
||||
const queryCapabilities = requireQueryPanelDefinition(unknownKind)
|
||||
.queryCapabilities;
|
||||
expect(queryCapabilities.requestType).toBe(time_series);
|
||||
expect(queryCapabilities.serverPaginated).toBe(false);
|
||||
expect(queryCapabilities.formatTableResultForUI).toBe(false);
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import type { FilterConfigsPartial } from './types/panelCapabilities';
|
||||
import {
|
||||
mergeQueryBuilderFieldRule,
|
||||
type FilterConfigsPartial,
|
||||
} from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
/**
|
||||
@@ -13,11 +17,46 @@ import type { PanelKind } from './types/panelKind';
|
||||
* these functions then cover it automatically. Pure and side-effect free.
|
||||
*/
|
||||
|
||||
/** Renders from its own plugin spec — no query surface at all. */
|
||||
export function isQuerylessPanelKind(kind: PanelKind): boolean {
|
||||
return getPanelDefinition(kind).mode === 'static';
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's definition narrowed to the query arm, or null for a static kind.
|
||||
* The null is what hosts fork on; the accessors below fold it into "supports
|
||||
* nothing" for the guard questions.
|
||||
*/
|
||||
export function getQueryPanelDefinition(
|
||||
kind: PanelKind,
|
||||
): RenderableQueryPanelDefinition | null {
|
||||
const definition = getPanelDefinition(kind);
|
||||
return definition.mode === 'query' ? definition : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query arm, asserted present. For call sites that a host mounts only after
|
||||
* narrowing `mode === 'query'` but that read the definition by kind rather than
|
||||
* receiving it as a prop — the throw makes that invariant executable instead of
|
||||
* silently null-tolerant.
|
||||
*/
|
||||
export function requireQueryPanelDefinition(
|
||||
kind: PanelKind,
|
||||
): RenderableQueryPanelDefinition {
|
||||
const definition = getQueryPanelDefinition(kind);
|
||||
if (!definition) {
|
||||
throw new Error(
|
||||
`query machinery mounted for query-less panel kind ${kind} — the host must fork on definition.mode before this point`,
|
||||
);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/** Signals a kind can visualize. */
|
||||
export function getSupportedSignals(
|
||||
kind: PanelKind,
|
||||
): TelemetrytypesSignalDTO[] {
|
||||
return getPanelDefinition(kind).supportedSignals;
|
||||
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
|
||||
}
|
||||
|
||||
export function isSignalSupported(
|
||||
@@ -29,7 +68,7 @@ export function isSignalSupported(
|
||||
|
||||
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
|
||||
return getPanelDefinition(kind).supportedQueryTypes;
|
||||
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
|
||||
}
|
||||
|
||||
export function isQueryTypeSupportedByPanelKind(
|
||||
@@ -53,6 +92,10 @@ export function isPanelCombinationValid({
|
||||
queryType: EQueryType;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}): boolean {
|
||||
// A query-less kind ignores the query entirely, so it pairs with anything.
|
||||
if (isQuerylessPanelKind(kind)) {
|
||||
return true;
|
||||
}
|
||||
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
|
||||
return false;
|
||||
}
|
||||
@@ -73,7 +116,11 @@ export function resolveQueryType(
|
||||
preferred: EQueryType,
|
||||
): EQueryType {
|
||||
const supported = getSupportedQueryTypes(kind);
|
||||
return supported.includes(preferred) ? preferred : supported[0];
|
||||
if (supported.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
// A query-less kind has no supported types; the builder is the neutral answer.
|
||||
return supported[0] ?? EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +132,6 @@ export function getHiddenQueryBuilderFields(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const rule = getPanelDefinition(kind).queryBuilderFields;
|
||||
const perSignal = signal ? rule[signal] : undefined;
|
||||
return { ...rule.default, ...perSignal };
|
||||
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
|
||||
return mergeQueryBuilderFieldRule(rule, signal);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -10,7 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
displayName: 'Bar Chart',
|
||||
mode: 'query',
|
||||
icon: BarChart,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
@@ -13,7 +14,10 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -10,7 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
displayName: 'Histogram',
|
||||
mode: 'query',
|
||||
icon: BarChart,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
@@ -9,7 +10,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: true },
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
|
||||
import ListColumnsEditor from '../../../PanelEditor/ListColumnsEditor/ListColumnsEditor';
|
||||
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
|
||||
/**
|
||||
* List's editor pane: the query builder with the columns editor pinned below it.
|
||||
* Declared here so no editor host carries a List special case.
|
||||
*/
|
||||
function ListEditorPane({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
}: QueryEditorPaneProps): JSX.Element {
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
footer={
|
||||
<ListColumnsEditor spec={spec} onChangeSpec={onChangeSpec} signal={signal} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListEditorPane;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { List } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import ListEditorPane from './ListEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -11,7 +14,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
displayName: 'List',
|
||||
mode: 'query',
|
||||
icon: List,
|
||||
Renderer,
|
||||
EditorPane: ListEditorPane,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Hash } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -10,7 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
displayName: 'Number',
|
||||
mode: 'query',
|
||||
icon: Hash,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ChartPie } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -10,7 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
displayName: 'Pie Chart',
|
||||
mode: 'query',
|
||||
icon: ChartPie,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolvePieLegendSeries } from '../../utils/legendSeries';
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
|
||||
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
|
||||
@@ -8,6 +9,9 @@ export const sections: SectionConfig[] = [
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolvePieLegendSeries },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Table } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -10,7 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
displayName: 'Table',
|
||||
mode: 'query',
|
||||
icon: Table,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -10,7 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
displayName: 'Time Series',
|
||||
mode: 'query',
|
||||
icon: ChartLine,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
@@ -11,7 +12,10 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
NO_PANEL_ACTIONS,
|
||||
type RenderablePanelDefinition,
|
||||
} from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,11 @@ import Renderer from './Renderer';
|
||||
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
|
||||
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
|
||||
displayName: 'Unsupported panel',
|
||||
mode: 'query',
|
||||
// Never offered in the UI — the kind lists come from the registry, which omits this.
|
||||
icon: TriangleAlert,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections: [],
|
||||
supportedSignals: [],
|
||||
supportedQueryTypes: [],
|
||||
|
||||
@@ -7,22 +7,33 @@ import { definition as Table } from './kinds/TablePanel/definition';
|
||||
import { definition as List } from './kinds/ListPanel/definition';
|
||||
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
|
||||
import type {
|
||||
PanelDefinition,
|
||||
PanelRegistry,
|
||||
RenderablePanelDefinition,
|
||||
} from './types/panelDefinition';
|
||||
import { PanelKind } from './types/panelKind';
|
||||
|
||||
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
|
||||
// Declaration order is the order kinds are offered in the UI.
|
||||
export const PANELS: PanelRegistry = {
|
||||
[TimeSeries.kind]: TimeSeries,
|
||||
[BarChart.kind]: BarChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[NumberValue.kind]: NumberValue,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Table.kind]: Table,
|
||||
[BarChart.kind]: BarChart,
|
||||
[PieChart.kind]: PieChart,
|
||||
[Histogram.kind]: Histogram,
|
||||
[List.kind]: List,
|
||||
};
|
||||
|
||||
export type PanelOption = Pick<
|
||||
PanelDefinition,
|
||||
'kind' | 'displayName' | 'icon'
|
||||
>;
|
||||
|
||||
// Backs both the new-panel picker and the editor's kind switcher; derived from PANELS
|
||||
// so a registered kind can't end up unreachable from the UI.
|
||||
export const PANEL_OPTIONS: PanelOption[] = Object.values(PANELS);
|
||||
|
||||
/**
|
||||
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
|
||||
* but a dashboard spec written by a newer SigNoz can name one this client has never heard
|
||||
|
||||
@@ -22,6 +22,15 @@ export type QueryBuilderFieldRule = {
|
||||
default?: FilterConfigsPartial;
|
||||
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
|
||||
|
||||
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
|
||||
export function mergeQueryBuilderFieldRule(
|
||||
rule: QueryBuilderFieldRule,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const perSignal = signal ? rule[signal] : undefined;
|
||||
return { ...rule.default, ...perSignal };
|
||||
}
|
||||
|
||||
/**
|
||||
* How a kind's query-range request is shaped. Declared per-kind in
|
||||
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartLine } from '@signozhq/icons';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
@@ -9,7 +13,11 @@ import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldRule,
|
||||
} from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
PanelRendererProps,
|
||||
StaticRendererProps,
|
||||
} from './rendererProps';
|
||||
|
||||
/** Export formats offered under the single "Download" action. */
|
||||
export enum DownloadFormat {
|
||||
@@ -60,11 +68,53 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
|
||||
drilldown: false,
|
||||
};
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
// Derived from an icon component so the props stay exact (size is a constrained
|
||||
// IconSize union) and ForwardRef-compatible.
|
||||
export type PanelIcon = typeof ChartLine;
|
||||
|
||||
export interface PanelDefinitionBase<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
icon: PanelIcon;
|
||||
sections: SectionConfig[];
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/** Props for a static kind's authoring pane — rendered where the query builder sits. */
|
||||
export interface StaticEditorPaneProps {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for a query kind's authoring pane (the editor's lower-left slot). Spec
|
||||
* read/write is included so a kind's pane can edit its own spec slices (the List
|
||||
* columns editor) without the host carrying per-kind conditionals.
|
||||
*/
|
||||
export interface QueryEditorPaneProps {
|
||||
/** The kind's definition, narrowed by the host's fork. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
isLoadingQueries: boolean;
|
||||
onStageRunQuery: () => void;
|
||||
onCancelQuery: () => void;
|
||||
/** Pin the tabs row to the pane top; the View modal opts out. */
|
||||
stickyHeader?: boolean;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind that renders from a query. Declares its whole query surface here, so a
|
||||
* kind without one carries no query declarations at all — no dummy capabilities,
|
||||
* no empty signal lists standing in for "not applicable".
|
||||
*/
|
||||
export interface QueryPanelDefinition<K extends PanelKind = PanelKind>
|
||||
extends PanelDefinitionBase<K> {
|
||||
mode: 'query';
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
|
||||
EditorPane: ComponentType<QueryEditorPaneProps>;
|
||||
/** Signals this kind can visualize. */
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
@@ -73,16 +123,38 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
queryBuilderFields: QueryBuilderFieldRule;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
actions: PanelActionCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind that renders from its own plugin spec and saves with `queries: []` (the
|
||||
* API rejects anything else). Its renderer takes no query data, and its editor
|
||||
* pane replaces the query builder (TDD D8). No query machinery mounts for it
|
||||
* anywhere — every host forks on `mode` before touching a query hook.
|
||||
*/
|
||||
export interface StaticPanelDefinition<K extends PanelKind = PanelKind>
|
||||
extends PanelDefinitionBase<K> {
|
||||
mode: 'static';
|
||||
Renderer: ComponentType<StaticRendererProps<K>>;
|
||||
EditorPane: ComponentType<StaticEditorPaneProps>;
|
||||
}
|
||||
|
||||
export type PanelDefinition<K extends PanelKind = PanelKind> =
|
||||
| QueryPanelDefinition<K>
|
||||
| StaticPanelDefinition<K>;
|
||||
|
||||
// Every kind must be registered, so getPanelDefinition never returns undefined.
|
||||
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
|
||||
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
// The arms with their Renderer widened to the kind-agnostic prop surface. Declared
|
||||
// explicitly rather than via `Omit` over the union, which collapses to common keys.
|
||||
export interface RenderableQueryPanelDefinition extends Omit<
|
||||
QueryPanelDefinition,
|
||||
'Renderer'
|
||||
> {
|
||||
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
|
||||
}
|
||||
export type RenderableStaticPanelDefinition = StaticPanelDefinition<PanelKind>;
|
||||
|
||||
export type RenderablePanelDefinition =
|
||||
| RenderableQueryPanelDefinition
|
||||
| RenderableStaticPanelDefinition;
|
||||
|
||||
@@ -75,6 +75,19 @@ export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Props a static (query-less) renderer receives: its panel and render context,
|
||||
* nothing of the fetch lifecycle. `dashboardId` scopes store reads (resolved
|
||||
* variables); the editor route seeds the same store, so previews of an unsaved
|
||||
* panel resolve variables the way the grid does.
|
||||
*/
|
||||
export interface StaticRendererProps<K extends PanelKind = PanelKind> {
|
||||
panelId: string;
|
||||
panel: PanelOfKind<K>;
|
||||
panelMode: PanelMode;
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
|
||||
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
|
||||
// and only the gestures it supports. The default K = PanelKind is the widest surface.
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { LegendSeriesResolver } from '../utils/legendSeries';
|
||||
import {
|
||||
Antenna,
|
||||
BarChart,
|
||||
@@ -105,7 +106,12 @@ export interface SectionControls {
|
||||
columnUnits?: boolean;
|
||||
};
|
||||
[SectionKind.Axes]: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
|
||||
[SectionKind.Legend]: { position?: boolean; colors?: boolean }; // colors → customColors
|
||||
[SectionKind.Legend]: {
|
||||
position?: boolean;
|
||||
// colors → customColors; the resolver supplies the labels overrides are keyed by,
|
||||
// so a kind can't offer color overrides with nothing to color
|
||||
colors?: LegendSeriesResolver;
|
||||
};
|
||||
[SectionKind.ChartAppearance]: {
|
||||
lineStyle?: boolean;
|
||||
lineInterpolation?: boolean;
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('buildPluginSpec', () => {
|
||||
|
||||
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
|
||||
const result = buildPluginSpec([
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
|
||||
]);
|
||||
|
||||
expect(result).toStrictEqual({});
|
||||
@@ -129,7 +129,7 @@ describe('buildPluginSpec', () => {
|
||||
it('seeds neither when their defaulting controls are absent', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: true } },
|
||||
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
|
||||
];
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
@@ -180,7 +180,10 @@ describe('buildPluginSpec', () => {
|
||||
|
||||
it('carries old legend position but never customColors', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: (): [] => [] },
|
||||
},
|
||||
];
|
||||
const oldSpec = oldSpecWith({
|
||||
legend: {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SectionKind, ThresholdVariant } from '../../types/sections';
|
||||
import { getSectionControls } from '../getSectionControls';
|
||||
|
||||
describe('getSectionControls', () => {
|
||||
it('returns the controls a kind declares for a section', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/TimeSeriesPanel', SectionKind.Formatting),
|
||||
).toStrictEqual({ unit: true, decimals: true });
|
||||
});
|
||||
|
||||
it('distinguishes kinds that key units per column from kinds with a panel unit', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/TablePanel', SectionKind.Formatting)?.unit,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getSectionControls('signoz/TablePanel', SectionKind.Formatting)?.columnUnits,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the threshold variant each kind edits', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/NumberPanel', SectionKind.Thresholds)?.variant,
|
||||
).toBe(ThresholdVariant.COMPARISON);
|
||||
expect(
|
||||
getSectionControls('signoz/BarChartPanel', SectionKind.Thresholds)?.variant,
|
||||
).toBe(ThresholdVariant.LABEL);
|
||||
});
|
||||
|
||||
it('returns undefined when the kind does not expose the section', () => {
|
||||
expect(
|
||||
getSectionControls('signoz/ListPanel', SectionKind.Formatting),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getSectionControls('signoz/HistogramPanel', SectionKind.Thresholds),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an unregistered kind', () => {
|
||||
expect(
|
||||
getSectionControls(
|
||||
'signoz/FuturePanel' as Parameters<typeof getSectionControls>[0],
|
||||
SectionKind.Formatting,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getPanelDefinition } from '../registry';
|
||||
import { getQueryPanelDefinition } from '../capabilities';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
|
||||
import { fromPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
@@ -10,10 +10,19 @@ import { fromPerses } from '../../queryV5/persesQueryAdapters';
|
||||
* The panel's saved query as a builder `Query` — what the editor route and the View
|
||||
* modal put in `compositeQuery` when they open. Matches the seed
|
||||
* `usePanelEditorQuerySync` computes from the panel.
|
||||
*
|
||||
* `null` for a kind that renders from its own spec: there is no query to stage, and
|
||||
* callers skip the `compositeQuery` param entirely rather than asking about the kind.
|
||||
*/
|
||||
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
|
||||
export function getPanelBuilderQuery(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): Query | null {
|
||||
const kind = panel.spec.plugin.kind;
|
||||
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
|
||||
const definition = getQueryPanelDefinition(kind);
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
const [defaultSignal] = definition.supportedSignals;
|
||||
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
|
||||
// metrics default isn't authorable in every kind (e.g. List).
|
||||
if (panel.spec.queries.length === 0 && defaultSignal) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getPanelDefinition } from '../registry';
|
||||
import type { PanelKind } from '../types/panelKind';
|
||||
import type { ControlledSectionKind, SectionControls } from '../types/sections';
|
||||
|
||||
/**
|
||||
* The controls a kind declares for one section, or `undefined` when it doesn't expose
|
||||
* that section — so callers read `kinds/<Kind>/sections.ts` instead of switching on kind.
|
||||
*/
|
||||
export function getSectionControls<K extends ControlledSectionKind>(
|
||||
kind: PanelKind,
|
||||
sectionKind: K,
|
||||
): SectionControls[K] | undefined {
|
||||
const section = getPanelDefinition(kind).sections.find(
|
||||
(candidate) => candidate.kind === sectionKind,
|
||||
);
|
||||
if (!section || !('controls' in section)) {
|
||||
return undefined;
|
||||
}
|
||||
// `find` can't correlate the matched member's `controls` with `sectionKind`; the
|
||||
// SectionConfig union guarantees it.
|
||||
return section.controls as SectionControls[K];
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
|
||||
import { getBuilderQueries } from './getBuilderQueries';
|
||||
import { resolveSeriesLabelV5 } from './resolveSeriesLabel';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
@@ -22,6 +22,15 @@ export interface LegendSeries {
|
||||
|
||||
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
|
||||
|
||||
export interface LegendSeriesArgs {
|
||||
queries: PanelQueries;
|
||||
data: PanelQueryData;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
/** Resolves a kind's output into the legend entries the colors control keys overrides by. */
|
||||
export type LegendSeriesResolver = (args: LegendSeriesArgs) => LegendSeries[];
|
||||
|
||||
/**
|
||||
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
|
||||
* pairs, resolving each unique label's color lazily via `colorFor` — so a repeated
|
||||
@@ -48,10 +57,10 @@ function buildLegendSeries(
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
* overrides by the same labels the chart does.
|
||||
*/
|
||||
export function resolvePieLegendSeries(
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
export function resolvePieLegendSeries({
|
||||
data,
|
||||
isDarkMode,
|
||||
}: LegendSeriesArgs): LegendSeries[] {
|
||||
const slices = preparePieData({
|
||||
tables: prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
@@ -70,11 +79,11 @@ export function resolvePieLegendSeries(
|
||||
* Time-series kinds: resolve each flattened series' label the way the renderer does
|
||||
* (`getLabelName` → `resolveSeriesLabelV5`) and color it with `generateColor`.
|
||||
*/
|
||||
export function resolveTimeSeriesLegendSeries(
|
||||
queries: PanelQueries,
|
||||
data: PanelQueryData,
|
||||
isDarkMode: boolean,
|
||||
): LegendSeries[] {
|
||||
export function resolveTimeSeriesLegendSeries({
|
||||
queries,
|
||||
data,
|
||||
isDarkMode,
|
||||
}: LegendSeriesArgs): LegendSeries[] {
|
||||
const palette = isDarkMode
|
||||
? themeColors.chartcolors
|
||||
: themeColors.lightModeColor;
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import {
|
||||
getPanelDefinition,
|
||||
isPanelKindSupported,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
import { useDrilldown } from './hooks/useDrilldown';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import styles from './Panel.module.scss';
|
||||
import QueryPanel from './QueryPanel';
|
||||
import StaticPanel from './StaticPanel';
|
||||
|
||||
/**
|
||||
* Layout context for the panel actions menu — present only in editable mode. No
|
||||
@@ -37,8 +24,8 @@ interface PanelProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A single dashboard panel (header + body). Thin orchestrator: fetching lives in
|
||||
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
|
||||
* A single dashboard panel. Forks on the kind's mode before any query machinery
|
||||
* exists, so a static kind never mounts a fetch — not even a disabled one.
|
||||
*/
|
||||
function Panel({
|
||||
panel,
|
||||
@@ -46,74 +33,28 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
// Header search: only kinds that declare it render the box. The term is owned
|
||||
// here and threaded to both the header (input) and renderer (filter).
|
||||
const searchable = panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
|
||||
// observer reports visibility (the View modal, the editor preview), and those panels
|
||||
// are on screen by construction.
|
||||
const isOffScreen = isVisible === false;
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
queryCapabilities: panelDefinition.queryCapabilities,
|
||||
// Lazy: fetch once on screen, and never for a kind this build can't render —
|
||||
// the data would have nothing to render into.
|
||||
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticPanel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelDefinition={panelDefinition}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isOffScreen ? 'false' : 'true'}
|
||||
// Stable locator so the "Download as PNG" action can find this node to
|
||||
// capture, without threading a ref through the header/actions chain.
|
||||
data-panel-root={panelId}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
timeLabel={timeLabel}
|
||||
panelActions={panelActions}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isVisible={isVisible}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
<QueryPanel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelDefinition={panelDefinition}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import PanelLoader from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
|
||||
import PanelMessage from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
DashboardPreference,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { hasRunnableQueries } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
|
||||
import { getResponseType } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
import type {
|
||||
@@ -17,8 +20,8 @@ import { panelStatusFromError } from '../PanelStatus/utils';
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface PanelBodyProps {
|
||||
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The query arm's renderer — hosts narrow `mode === 'query'` before this mounts. */
|
||||
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
data: PanelQueryData;
|
||||
@@ -51,7 +54,7 @@ interface PanelBodyProps {
|
||||
* first-load / renderer. The renderer keeps stale data mounted across refetches.
|
||||
*/
|
||||
function PanelBody({
|
||||
panelDefinition,
|
||||
Renderer,
|
||||
panel,
|
||||
panelId,
|
||||
data,
|
||||
@@ -116,7 +119,7 @@ function PanelBody({
|
||||
|
||||
return (
|
||||
<div className={styles.panelContainer}>
|
||||
<panelDefinition.Renderer
|
||||
<Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import PanelBody from '../PanelBody';
|
||||
@@ -8,10 +7,6 @@ import PanelBody from '../PanelBody';
|
||||
// Stub the renderer so these tests focus on PanelBody's state machine.
|
||||
const MockRenderer = (): JSX.Element => <div data-testid="mock-renderer" />;
|
||||
|
||||
const panelDefinition = {
|
||||
Renderer: MockRenderer,
|
||||
} as unknown as RenderablePanelDefinition;
|
||||
|
||||
function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
@@ -24,7 +19,7 @@ function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
panelDefinition,
|
||||
Renderer: MockRenderer,
|
||||
panelId: 'p1',
|
||||
data: {} as PanelQueryData,
|
||||
isFetching: false,
|
||||
|
||||
@@ -4,8 +4,8 @@ import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from './constants';
|
||||
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
@@ -91,19 +91,19 @@ function PanelTypeSelectionModal({
|
||||
<span className={styles.pickerLabel}>Select panel type</span>
|
||||
)}
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
|
||||
{PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => (
|
||||
<button
|
||||
key={panelKind}
|
||||
key={kind}
|
||||
type="button"
|
||||
className={cx(styles.panelTypeCard, {
|
||||
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
|
||||
[styles.panelTypeCardSelected]: kind === selectedPanelKind,
|
||||
})}
|
||||
data-testid={`panel-type-${panelKind}`}
|
||||
aria-pressed={panelKind === selectedPanelKind}
|
||||
onClick={(): void => handleTileClick(panelKind)}
|
||||
data-testid={`panel-type-${kind}`}
|
||||
aria-pressed={kind === selectedPanelKind}
|
||||
onClick={(): void => handleTileClick(kind)}
|
||||
>
|
||||
<Icon size={24} color={Color.BG_ROBIN_400} />
|
||||
{label}
|
||||
{displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import type { PanelType } from './types';
|
||||
|
||||
export const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
Icon: ChartLine,
|
||||
},
|
||||
{ panelKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
|
||||
{ panelKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
|
||||
{ panelKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
|
||||
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
|
||||
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
|
||||
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
|
||||
];
|
||||
@@ -1,20 +1,11 @@
|
||||
import type { IconSize } from '@signozhq/icons';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
size?: number | IconSize;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
export interface PanelType {
|
||||
panelKind: PanelKind;
|
||||
label: string;
|
||||
/** Icon component — the consumer renders it and controls size/color/etc. */
|
||||
Icon: ComponentType<IconProps>;
|
||||
}
|
||||
|
||||
export interface SectionOption {
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
value: string;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
getPanelTimePreference,
|
||||
panelTimePreferenceLabel,
|
||||
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
|
||||
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
|
||||
|
||||
import type { PanelActionsConfig } from './Panel';
|
||||
import { useDrilldown } from './hooks/useDrilldown';
|
||||
import { usePanelInteractions } from './hooks/usePanelInteractions';
|
||||
import PanelBody from './PanelBody/PanelBody';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
interface QueryPanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** The kind's definition, narrowed to the query arm by `Panel`'s fork. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
|
||||
isVisible?: boolean;
|
||||
/** Move/delete actions — present only in editable sectioned mode. */
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A query-backed dashboard panel (header + body). Thin orchestrator: fetching
|
||||
* lives in `usePanelQuery`, interactions in `usePanelInteractions`, state in
|
||||
* `PanelBody`.
|
||||
*/
|
||||
function QueryPanel({
|
||||
panel,
|
||||
panelId,
|
||||
panelDefinition,
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: QueryPanelProps): JSX.Element {
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
|
||||
// Header search: only kinds that declare it render the box. The term is owned
|
||||
// here and threaded to both the header (input) and renderer (filter).
|
||||
const searchable = panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
|
||||
// observer reports visibility (the View modal, the editor preview), and those panels
|
||||
// are on screen by construction.
|
||||
const isOffScreen = isVisible === false;
|
||||
|
||||
const { data, isFetching, isPreviousData, error, refetch, pagination } =
|
||||
usePanelQuery({
|
||||
panel,
|
||||
panelId,
|
||||
queryCapabilities: panelDefinition.queryCapabilities,
|
||||
// Lazy: fetch once on screen, and never for a kind this build can't render —
|
||||
// the data would have nothing to render into.
|
||||
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isOffScreen ? 'false' : 'true'}
|
||||
// Stable locator so the "Download as PNG" action can find this node to
|
||||
// capture, without threading a ref through the header/actions chain.
|
||||
data-panel-root={panelId}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
timeLabel={timeLabel}
|
||||
panelActions={panelActions}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isVisible={isVisible}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryPanel;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelActionsConfig } from './Panel';
|
||||
import PanelHeader from './PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from './StaticPanelBody/StaticPanelBody';
|
||||
import styles from './Panel.module.scss';
|
||||
|
||||
interface StaticPanelProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
isVisible?: boolean;
|
||||
panelActions?: PanelActionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dashboard panel that renders from its own plugin spec: chrome plus the static
|
||||
* body. No fetch, no status indicators, no time preference, no drilldown — none
|
||||
* of that exists without a query.
|
||||
*/
|
||||
function StaticPanel({
|
||||
panel,
|
||||
panelId,
|
||||
panelDefinition,
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: StaticPanelProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
// Stable locator, as on QueryPanel — actions that capture the panel node
|
||||
// (and tests) address it the same way for both arms.
|
||||
data-panel-root={panelId}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={EMPTY_PANEL_QUERY_DATA}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
timeLabel={null}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
<StaticPanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StaticPanel;
|
||||
@@ -0,0 +1,5 @@
|
||||
.body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import styles from './StaticPanelBody.module.scss';
|
||||
|
||||
interface StaticPanelBodyProps {
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
|
||||
panelMode?: PanelMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for a kind that renders from its own plugin spec: the static renderer and
|
||||
* nothing else — no fetch, no loading or error states. Shared by the dashboard
|
||||
* grid, the public view and the editor preview, which is what keeps the preview
|
||||
* live while the draft spec changes.
|
||||
*/
|
||||
function StaticPanelBody({
|
||||
panelDefinition,
|
||||
panel,
|
||||
panelId,
|
||||
panelMode = PanelMode.DASHBOARD_VIEW,
|
||||
}: StaticPanelBodyProps): JSX.Element {
|
||||
// From the edit context, not props: the editor route seeds it too, so an
|
||||
// unsaved panel's preview resolves variables the same way the grid does.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const { Renderer } = panelDefinition;
|
||||
|
||||
return (
|
||||
<div className={styles.body} data-testid="static-panel-body">
|
||||
<Renderer
|
||||
panelId={panelId}
|
||||
panel={panel as PanelOfKind}
|
||||
panelMode={panelMode}
|
||||
dashboardId={dashboardId || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StaticPanelBody;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
|
||||
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelMode } from './useViewPanelMode';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
DashboardDetailEvents,
|
||||
DashboardEvents,
|
||||
} from 'pages/DashboardPage/constants/events';
|
||||
|
||||
interface QueryViewModalBodyProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Close the modal — wired to the graph manager's Save/Cancel. */
|
||||
onClose: () => void;
|
||||
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
|
||||
draftApi: PanelEditorDraftApi;
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query-kind View modal body: a compact drilldown editor. It renders an
|
||||
* editable draft of the panel (preview) over a per-view time window plus the
|
||||
* kind's editor pane, so the user can tweak + Stage & Run without touching the
|
||||
* dashboard. Edits are temporary.
|
||||
*/
|
||||
function QueryViewModalBody({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
draftApi,
|
||||
onChangePanelKind,
|
||||
}: QueryViewModalBodyProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
} = useViewPanelTimeWindow();
|
||||
|
||||
const {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
query,
|
||||
runQuery,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride, draftApi });
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
const drilldown = useDrilldown(draft, panelId, {
|
||||
openDrilldownView: applyDrilldownQuery,
|
||||
});
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
const isolatedPreference = useMemo<DashboardPreference>(
|
||||
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
|
||||
[dashboardPreference],
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
|
||||
const handleDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (Math.floor(start) !== Math.floor(end)) {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, {
|
||||
context: 'viewModal',
|
||||
panelType: draft.spec.plugin.kind,
|
||||
panelId,
|
||||
});
|
||||
}
|
||||
onDragSelect(start, end);
|
||||
},
|
||||
[onDragSelect, draft.spec.plugin.kind, panelId],
|
||||
);
|
||||
|
||||
// Publish the modal's local extender for the nested no-data state; cleared on close.
|
||||
const setViewPanelExtendWindow = useViewPanelStore(
|
||||
(s) => s.setViewPanelExtendWindow,
|
||||
);
|
||||
useEffect(() => {
|
||||
setViewPanelExtendWindow(extendWindow);
|
||||
return (): void => setViewPanelExtendWindow(null);
|
||||
}, [extendWindow, setViewPanelExtendWindow]);
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
}
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
const onSwitchToEdit = (): void => {
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, {
|
||||
handoffState: { editSpec: buildSaveSpec(draft.spec) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
selectedInterval={selectedInterval}
|
||||
startMs={timeOverride.startMs}
|
||||
endMs={timeOverride.endMs}
|
||||
onTimeChange={onTimeChange}
|
||||
isFetching={isFetching}
|
||||
onRefresh={(): void => {
|
||||
// Relative windows re-anchor to now (new key → refetch); a fixed
|
||||
// custom window just re-runs the same query.
|
||||
if (selectedInterval === 'custom') {
|
||||
refetch();
|
||||
} else {
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={onSwitchToEdit}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
/>
|
||||
<div className={styles.queryBuilder}>
|
||||
<EditorPane
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
stickyHeader={false}
|
||||
spec={draft.spec}
|
||||
onChangeSpec={setSpec}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={handleDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryViewModalBody;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback } from 'react';
|
||||
import { PenLine } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import PanelTypeSwitcher from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import { DashboardEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface StaticViewModalBodyProps {
|
||||
panelId: string;
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The static-kind View modal body: the panel rendered live over the kind's
|
||||
* editor pane — the same layout idea as the query body, with the time window,
|
||||
* query builder and drilldown machinery absent because none of it applies.
|
||||
* Edits are temporary; "Edit panel" hands them to the full editor.
|
||||
*/
|
||||
function StaticViewModalBody({
|
||||
panelId,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
}: StaticViewModalBodyProps): JSX.Element {
|
||||
const { draft, spec, setSpec } = draftApi;
|
||||
const { EditorPane } = panelDefinition;
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
const onSwitchToEdit = useCallback((): void => {
|
||||
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, { panelId });
|
||||
// Carry the in-modal edits so the editor opens on them, not the saved panel.
|
||||
openPanelEditor(panelId, {
|
||||
handoffState: { editSpec: { ...draft.spec, queries: [] } },
|
||||
});
|
||||
}, [openPanelEditor, panelId, draft.spec]);
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<div className={styles.staticToolbar}>
|
||||
<PanelTypeSwitcher
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<PenLine size={14} />}
|
||||
onClick={onSwitchToEdit}
|
||||
data-testid="static-view-switch-to-edit"
|
||||
>
|
||||
Edit panel
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.staticPreview}>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
data={EMPTY_PANEL_QUERY_DATA}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
hideActions
|
||||
/>
|
||||
<StaticPanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.staticEditorPane}>
|
||||
<EditorPane spec={spec} onChangeSpec={setSpec} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StaticViewModalBody;
|
||||
@@ -60,3 +60,29 @@
|
||||
.panelTypeSelector {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
// Static-kind modal body: toolbar, live panel, editor pane.
|
||||
.staticToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.staticPreview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 55%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.staticEditorPane {
|
||||
flex: 1 1 45%;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import ListColumnsEditor from 'pages/DashboardPage/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
|
||||
import PanelEditorQueryBuilder from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
import { useDrilldown } from '../hooks/useDrilldown';
|
||||
import { usePanelInteractions } from '../hooks/usePanelInteractions';
|
||||
import ViewPanelModalHeader from './ViewPanelModalHeader';
|
||||
import { useViewPanelMode } from './useViewPanelMode';
|
||||
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
DashboardDetailEvents,
|
||||
DashboardEvents,
|
||||
} from 'pages/DashboardPage/constants/events';
|
||||
import QueryViewModalBody from './QueryViewModalBody';
|
||||
import StaticViewModalBody from './StaticViewModalBody';
|
||||
import { readViewPanelHandoff } from './viewPanelHandoffStore';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -30,168 +26,79 @@ interface ViewPanelModalContentProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
|
||||
* the panel (preview) over a per-view time window plus the shared query builder, so the
|
||||
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
|
||||
* View-modal shell. Owns the draft and the kind-switch cache — the state that
|
||||
* must survive a switch between authoring modes — and forks on the draft kind's
|
||||
* `mode`, so a static kind mounts no time window, query session or drilldown.
|
||||
*/
|
||||
function ViewPanelModalContent({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
}: ViewPanelModalContentProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
} = useViewPanelTimeWindow();
|
||||
}: ViewPanelModalContentProps): JSX.Element {
|
||||
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the
|
||||
// query still comes from the URL. Falls back to the saved panel for a plain "View".
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
|
||||
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
draft,
|
||||
setSpec,
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
} = useViewPanelMode({ panel, panelId, time: timeOverride });
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
cancelQuery,
|
||||
pagination,
|
||||
} = query;
|
||||
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() => {
|
||||
// A URL query can only seed a kind that takes one.
|
||||
const isQuerySeeded =
|
||||
compositeQuery && getPanelDefinition(baseSpec.plugin.kind).mode === 'query';
|
||||
return isQuerySeeded
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: baseSpec,
|
||||
query: compositeQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: { ...panel, spec: baseSpec };
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
|
||||
const draftApi = usePanelEditorDraft(initialPanel);
|
||||
const draftKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(draftKind);
|
||||
|
||||
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
|
||||
// so it reflects in-modal edits (and the click's time range follows the per-view window).
|
||||
const drilldown = useDrilldown(draft, panelId, {
|
||||
openDrilldownView: applyDrilldownQuery,
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[draftKind],
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
|
||||
// cursor-sync group so a drag here can't replay onto the grid panels.
|
||||
const { dashboardPreference } = usePanelInteractions();
|
||||
const isolatedPreference = useMemo<DashboardPreference>(
|
||||
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
|
||||
[dashboardPreference],
|
||||
);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
|
||||
const handleDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
if (Math.floor(start) !== Math.floor(end)) {
|
||||
void logEvent(DashboardDetailEvents.PanelZoomed, {
|
||||
context: 'viewModal',
|
||||
panelType: draft.spec.plugin.kind,
|
||||
panelId,
|
||||
});
|
||||
}
|
||||
onDragSelect(start, end);
|
||||
},
|
||||
[onDragSelect, draft.spec.plugin.kind, panelId],
|
||||
);
|
||||
|
||||
// Publish the modal's local extender for the nested no-data state; cleared on close.
|
||||
const setViewPanelExtendWindow = useViewPanelStore(
|
||||
(s) => s.setViewPanelExtendWindow,
|
||||
);
|
||||
useEffect(() => {
|
||||
setViewPanelExtendWindow(extendWindow);
|
||||
return (): void => setViewPanelExtendWindow(null);
|
||||
}, [extendWindow, setViewPanelExtendWindow]);
|
||||
|
||||
// The View action only appears for registered kinds, so this is defensive.
|
||||
if (!panelDefinition) {
|
||||
return null;
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticViewModalBody
|
||||
panelId={panelId}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onSwitchToEdit = (): void => {
|
||||
// Carry the drilldown edits so the editor opens on them, not the saved panel.
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, {
|
||||
handoffState: { editSpec: buildSaveSpec(draft.spec) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.content} data-testid="view-panel-modal-content">
|
||||
<ViewPanelModalHeader
|
||||
selectedInterval={selectedInterval}
|
||||
startMs={timeOverride.startMs}
|
||||
endMs={timeOverride.endMs}
|
||||
onTimeChange={onTimeChange}
|
||||
isFetching={isFetching}
|
||||
onRefresh={(): void => {
|
||||
// Relative windows re-anchor to now (new key → refetch); a fixed
|
||||
// custom window just re-runs the same query.
|
||||
if (selectedInterval === 'custom') {
|
||||
refetch();
|
||||
} else {
|
||||
refreshWindow();
|
||||
}
|
||||
}}
|
||||
onSwitchToEdit={onSwitchToEdit}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
/>
|
||||
<div className={styles.queryBuilder}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
signal={signal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
stickyHeader={false}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={draft.spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={signal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<PreviewPane
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={handleDragSelect}
|
||||
pagination={pagination}
|
||||
panelMode={PanelMode.STANDALONE_VIEW}
|
||||
dashboardPreference={isolatedPreference}
|
||||
onCloseStandaloneView={onClose}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
<QueryViewModalBody
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
onClose={onClose}
|
||||
draftApi={draftApi}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,12 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { usePanelEditSession } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
|
||||
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
|
||||
import type { OpenDrilldownView } from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
@@ -23,16 +18,15 @@ import {
|
||||
type PanelQueryTimeOverride,
|
||||
type UsePanelQueryResult,
|
||||
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { readViewPanelHandoff } from './viewPanelHandoffStore';
|
||||
|
||||
interface UseViewPanelModeArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
|
||||
time: PanelQueryTimeOverride;
|
||||
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
|
||||
draftApi: PanelEditorDraftApi;
|
||||
}
|
||||
|
||||
export interface UseViewPanelModeReturn {
|
||||
@@ -41,7 +35,7 @@ export interface UseViewPanelModeReturn {
|
||||
/** Update the draft's spec in place (e.g. the List columns editor). */
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/**
|
||||
* Builder datasource driving the query builder and the panel-type selector's
|
||||
* disabled rule. Resolved from the query, falling back to the kind's default
|
||||
@@ -54,8 +48,6 @@ export interface UseViewPanelModeReturn {
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
runQuery: () => void;
|
||||
/** Switch the draft's visualization kind (temporary; reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the query the view opened with, discarding in-modal edits. */
|
||||
resetQuery: () => void;
|
||||
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
|
||||
@@ -77,51 +69,20 @@ export function useViewPanelMode({
|
||||
panel,
|
||||
panelId,
|
||||
time,
|
||||
draftApi,
|
||||
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
|
||||
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
|
||||
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
|
||||
[],
|
||||
);
|
||||
|
||||
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const urlGraphType = useUrlQuery().get(
|
||||
QueryParams.graphType,
|
||||
) as PANEL_TYPES | null;
|
||||
const initialPanel = useMemo<DashboardtypesPanelDTO>(
|
||||
() =>
|
||||
compositeQuery
|
||||
? {
|
||||
...panel,
|
||||
spec: buildViewPanelSpec({
|
||||
spec: baseSpec,
|
||||
query: compositeQuery,
|
||||
panelType:
|
||||
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
|
||||
}),
|
||||
}
|
||||
: { ...panel, spec: baseSpec },
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
draft,
|
||||
panelDefinition,
|
||||
defaultSignal,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
buildSaveSpec,
|
||||
reset,
|
||||
setSpec,
|
||||
} = usePanelEditSession({ panel: initialPanel, panelId, time });
|
||||
} = usePanelEditSession({ panel, panelId, time, draftApi });
|
||||
|
||||
// The query the view opened with, captured once — the Reset target.
|
||||
const savedQuery = useMemo(
|
||||
@@ -178,7 +139,6 @@ export function useViewPanelMode({
|
||||
queryType: currentQuery.queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user