Compare commits

..

9 Commits

Author SHA1 Message Date
Abhi Kumar
61f668ca30 refactor(dashboards): let getPanelBuilderQuery answer for a query-less kind
Both callers that stage a panel's query into the URL asked the capabilities guard
whether the kind was query-less before calling getPanelBuilderQuery — the same
question, phrased the same way, in two hooks that otherwise share nothing.

The function itself knows: it already reads the kind's definition for a default
signal, so it returns null when there is no query arm and callers skip the
compositeQuery param on a falsy result.

Assisted-by: Claude Opus 5
2026-09-05 18:05:32 +05:30
Abhi Kumar
48b12c6247 refactor(dashboards): own the panel editor's frame once
Forking the editor on authoring mode copied the whole frame into both arms: the
header, both ResizablePanelGroups, the handles and the layout persistence. The
two differ only in the pane sizes and in what fills the preview and editor
slots — everything else was duplicated, including the layout ids, so the arms
shared persisted state while each owned its own copy of the sizes. Changing one
would have silently diverged them.

PanelEditorLayout owns the frame and takes the slots. The per-mode sizes sit
together in PANE_SPLIT, where they can be compared, instead of inline in two
files: the query builder is a compact form so the preview keeps the room, while
a static kind's editor pane is the surface being worked in.

Assisted-by: Claude Opus 5
2026-09-05 18:05:32 +05:30
Abhi Kumar
a97e9838ad refactor(dashboards): fork the View modal on authoring mode
Phase 3b of the Text panel plan — the View modal gets the same shell the
editor got: ViewPanelModalContent now owns the draft and the kind-switch cache
(so a switch across authoring modes survives the branch swap) plus the
mount-only URL/handoff seeding, guarded so a composite query can only seed a
kind that takes one. The query body is today's content unchanged
(QueryViewModalBody); the static body renders the panel live over the kind's
editor pane, with a kind switcher and an "Edit panel" handoff that carries
in-modal edits — no time window, no query session, no drilldown.

useViewPanelMode drops its seeding and switch concerns and takes the hoisted
draft, which is what keeps its query machinery unmounted for static kinds.
Opening a static panel writes only the expanded-panel id: with no query to
stage or persist, nothing reaches the URL or the shared builder.

Assisted-by: Claude Fable 5
(cherry picked from commit 7af199fcb2)
2026-09-05 17:35:06 +05:30
Abhi Kumar
368b2a0648 refactor(dashboards): fork the panel editor on authoring mode
Phase 3a of the Text panel plan. PanelEditorContainer becomes a shell owning
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`:

- QueryEditorBody is today's editor body unchanged, now fed the hoisted draft
  and the narrowed definition. The lower pane comes from the definition's
  EditorPane: a shared QueryBuilderEditorPane for six kinds, and a List wrapper
  that absorbs the columns-editor footer both the editor and the View modal
  previously special-cased inline.
- StaticEditorBody is the query-less body: the kind's editor pane under a live
  preview of the draft (the same StaticPanelBody the grid renders), saving with
  `queries: []` — the only shape the API accepts. No builder seeding, no staged
  run, no compositeQuery URL writes; opening the editor on a static kind stamps
  no default query into the URL either.
- usePanelTypeSwitch handles static targets: first visit gets a fresh spec with
  queries emptied and the query builder left untouched; the per-kind cache makes
  the round trip restore both sides.

PanelEditorQueryBuilder now reads the narrowed definition it is handed instead
of looking capabilities up by kind, which also breaks the would-be import cycle
definition → pane → capabilities → registry → definition.

Still unreachable: no static kind is registered until the registration phase.

Assisted-by: Claude Fable 5
(cherry picked from commit f065874402)
2026-09-05 17:35:06 +05:30
Abhi Kumar
46e6132297 feat(dashboards): render static panel kinds on the grid and public view
Phase 2 of the Text panel plan. StaticPanel (grid) and StaticPublicPanel mount
a static kind's renderer behind the shared panel chrome — no fetch, no status
indicators, no time preference, no drilldown, because none of that exists
without a query. StaticPanelBody is shared by both hosts (and later the editor
preview) and resolves `dashboardId` from the edit-context store, so previews of
unsaved panels read variables the same way the grid does.

Still unreachable: no static kind is registered, so both arms are exercised by
fork tests with the registry mocked — asserting the query hook never mounts.

(cherry picked from commit 1f1fe69f30)
2026-09-05 17:35:06 +05:30
Abhi Kumar
f54a33c3ee refactor(dashboards): split PanelDefinition into query and static arms
Phase 1 of the Text panel plan (frontend/docs/text-panel-implementation-plan.md).

A definition is now one of two shapes discriminated by a root `mode`: query
kinds declare their whole query surface (renderer, signals, query types,
builder fields, request capabilities); static kinds — none exist yet — declare
a renderer that takes no query data and an editor pane that replaces the query
builder. No dummy capabilities, no empty declarations standing in for "not
applicable".

Hosts fork on `mode` and pass the narrowed definition down: Panel and
PublicPanel become hookless forks over extracted QueryPanel/QueryPublicPanel
bodies, PanelBody takes the query arm's Renderer directly, and readers that
can't take the definition as a prop yet assert the arm via
requireQueryPanelDefinition. Leaf query hooks keep non-null contracts.

No behavior change: generated types are untouched, so the kind universe is
still the seven query kinds and the static arm of every fork is unreachable.

(cherry picked from commit 526ff46441)
2026-09-05 17:35:05 +05:30
Abhi Kumar
c170707757 refactor(dashboards): declare the legend colors control as its resolver
useLegendSeries switched on the panel kind to pick how a panel's output becomes
legend entries — pie slices or flat series — while the kinds that expose the
colors control were already declaring it as Legend.controls.colors. Two places
described the same fact, so a new chart kind could declare the control and
silently get an empty color picker.

The colors control now carries the resolver instead of a boolean: declaring the
resolver is declaring the control, so the two cannot disagree. The hook becomes a
lookup and a call with no switch, and LegendSection's truthiness check is
unchanged.

legendSeries.ts moves from PanelEditor/utils to Panels/utils — it only ever
imported from Panels and queryV5, and kinds cannot import up into the editor. Both
resolvers take one args object so pie needs no placeholder parameter.

Assisted-by: Claude Opus 5
2026-09-05 17:33:10 +05:30
Abhi Kumar
f6c34795a5 refactor(dashboards): read alert units and thresholds from the declarations
Both alert helpers switched on the panel kind: readPanelUnit listed the four
kinds that carry a unit, readPanelThresholds listed the shapes each kind's
thresholds take. Every new kind has to be added to both, and a missed one loses
its alert prefill silently — there is no failure, just a missing unit.

Both facts are already declared. A kind's Formatting section says whether it
exposes a unit, and its Thresholds section says which variant it edits, so
getSectionControls answers both by reading the kind's own sections.ts. It also
replaces the private copy of the same lookup in newPanelSeed.

A table variant contributes no prefill: its thresholds are per column, which has
no meaning for a panel-wide alert condition.

Assisted-by: Claude Opus 5
2026-09-05 17:32:59 +05:30
Abhi Kumar
37ef1cd1db refactor(dashboards): offer panel kinds from the registry
The new-panel picker and the editor's kind switcher both read a hand-maintained
PANEL_TYPES array. A kind absent from it renders fine on a saved dashboard but
can never be created or switched to, and nothing catches the omission.

Each kind now declares its picker icon next to the displayName it already
declares, and both surfaces render Object.values(PANELS). Registry declaration
order is display order, so registry.ts is reordered to keep today's tile order.
The parallel array and its PanelType interface are deleted.

Assisted-by: Claude Opus 5
2026-09-05 17:32:40 +05:30
115 changed files with 2224 additions and 5747 deletions

View File

@@ -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;

View File

@@ -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
);
}

View File

@@ -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>
);
}

View File

@@ -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%)',
});
});
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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 500ms1s 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();
});
});

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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 });
});
});

View File

@@ -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 0100ms 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]);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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);
});
});

View File

@@ -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));
});
});
});

View File

@@ -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,
};
}

View File

@@ -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();
}

View File

@@ -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 };
}

View File

@@ -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;
},
};
}

View File

@@ -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';
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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 => (

View File

@@ -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}
/>
);
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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']

View File

@@ -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,

View File

@@ -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,

View File

@@ -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[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from '../../../../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -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(

View File

@@ -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';

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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}

View File

@@ -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(),

View File

@@ -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]);
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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}
/>
);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 },
];

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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: [],

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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();
});
});

View File

@@ -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) {

View File

@@ -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];
}

View File

@@ -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;

View File

@@ -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}
/>
);
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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 },
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
.body {
height: 100%;
min-height: 0;
overflow: hidden;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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}
/>
);
}

View File

@@ -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