mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-20 19:50:44 +01:00
Compare commits
5 Commits
feat/chart
...
feat/heatm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67f7e69ce | ||
|
|
b47245d46a | ||
|
|
52ec7bf128 | ||
|
|
afe62d77b0 | ||
|
|
1aa6346a4c |
@@ -2,6 +2,8 @@
|
||||
|
||||
// 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;
|
||||
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
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(),
|
||||
};
|
||||
};
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
_target: HTMLElement,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance();
|
||||
return createMockUPlotInstance(target);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -19,6 +19,7 @@ export type LogDetailProps = {
|
||||
onScrollToLog?: (logId: string) => void;
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Link,
|
||||
} from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { MouseEvent, MouseEventHandler } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
@@ -67,6 +68,11 @@ function LogDetailsHeader({
|
||||
},
|
||||
];
|
||||
|
||||
const rawTimestamp = log.date ?? log.timestamp;
|
||||
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
|
||||
? rawTimestamp
|
||||
: normalizeTimeToMs(rawTimestamp);
|
||||
|
||||
return (
|
||||
<div className={styles.header} data-log-detail-ignore="true">
|
||||
<div className={styles.leftSection}>
|
||||
@@ -76,7 +82,7 @@ function LogDetailsHeader({
|
||||
data-testid="log-details-header-timestamp"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
log.date ?? log.timestamp,
|
||||
displayTimestamp,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -18,10 +18,9 @@ jest.mock('periscope/components/DataViewer', () => ({
|
||||
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
|
||||
}));
|
||||
|
||||
// The flag to be removed later
|
||||
jest.mock('../constants', () => ({
|
||||
...jest.requireActual('../constants'),
|
||||
isLogDetailsV2: true,
|
||||
// Force v2 for these tests regardless of route.
|
||||
jest.mock('../useIsLogDetailsV2', () => ({
|
||||
useIsLogDetailsV2: (): boolean => true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
@@ -92,6 +91,24 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a nanosecond-epoch timestamp in the header', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
|
||||
|
||||
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
|
||||
// Must scale to ms, not render a wildly wrong date.
|
||||
renderDrawer({
|
||||
log: {
|
||||
...mockLog,
|
||||
date: '1705311930000000000',
|
||||
timestamp: 1705311930000000000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
|
||||
'Jan 15, 2024 ⎯ 09:45:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('copies the log link from the ⋯ menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// temporary flag to be removed with old log details code.
|
||||
export const isLogDetailsV2 = true;
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: 'OVERVIEW',
|
||||
JSON: 'JSON',
|
||||
|
||||
@@ -51,11 +51,12 @@ import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -74,6 +75,7 @@ function LogDetailInner({
|
||||
onScrollToLog,
|
||||
handleOpenInExplorer,
|
||||
getContainer,
|
||||
onApplyLogFilter,
|
||||
}: LogDetailInnerProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
@@ -92,6 +94,8 @@ function LogDetailInner({
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
@@ -516,6 +520,7 @@ function LogDetailInner({
|
||||
selectedOptions={options}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
|
||||
|
||||
11
frontend/src/components/LogDetail/useIsLogDetailsV2.ts
Normal file
11
frontend/src/components/LogDetail/useIsLogDetailsV2.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useIsLogDetailsV2(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
pathname === ROUTES.LOGS_EXPLORER ||
|
||||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
|
||||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,9 @@ export default function ChartWrapper({
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
customLegend,
|
||||
legendLabels,
|
||||
contentFooter,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -47,6 +50,10 @@ 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}
|
||||
@@ -55,7 +62,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend],
|
||||
[config, legendConfig.position, showLegend, customLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -86,6 +93,8 @@ export default function ChartWrapper({
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
seriesLabels={legendLabels}
|
||||
contentFooter={contentFooter}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import ChartWrapper from 'container/DashboardContainer/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/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 './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}
|
||||
timezone={timezone}
|
||||
customTooltip={renderTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
contentFooter={visualMap}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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;
|
||||
}
|
||||
@@ -31,6 +31,13 @@ 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 {
|
||||
|
||||
@@ -16,20 +16,31 @@ 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(
|
||||
() => {
|
||||
@@ -42,19 +53,20 @@ export default function ChartLayout({
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
const resolvedLabels =
|
||||
seriesLabels ??
|
||||
Object.values(config.getLegendItems())
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
return calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
seriesLabels,
|
||||
seriesLabels: resolvedLabels,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,6 +84,7 @@ export default function ChartLayout({
|
||||
chartHeight: chartDimensions.height,
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
{contentFooter}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
|
||||
@@ -82,6 +82,7 @@ function EntityLogsContent({
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
// TODO: Move away from using onAddToQuery after old drawer cleanup
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
@@ -104,6 +105,21 @@ function EntityLogsContent({
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const onApplyLogFilter = useCallback(
|
||||
(expression: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const newUser = userExpression.trim()
|
||||
? `${userExpression} AND ${expression}`
|
||||
: expression;
|
||||
|
||||
querySearchOnRun(newUser);
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
|
||||
},
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const {
|
||||
logs,
|
||||
loadMoreLogs,
|
||||
@@ -328,6 +344,7 @@ function EntityLogsContent({
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
|
||||
getContainer={(): HTMLElement =>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
|
||||
import { isLogDetailsV2 } from 'components/LogDetail/constants';
|
||||
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -41,6 +41,7 @@ interface OverviewProps {
|
||||
selectedOptions: OptionsQuery;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
type Props = OverviewProps &
|
||||
@@ -55,6 +56,7 @@ function Overview({
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
@@ -67,8 +69,11 @@ function Overview({
|
||||
const { actions, visibleActions } = useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel,
|
||||
onApplyLogFilter,
|
||||
});
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
if (isLogDetailsV2) {
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
VisibleActionsConfig,
|
||||
} from 'periscope/components/PrettyView/PrettyView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { LogDetailsAction } from '../constants';
|
||||
import {
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
interface UseLogAttributeActionsParams {
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
isListViewPanel?: boolean;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
interface UseLogAttributeActionsResult {
|
||||
@@ -50,6 +53,7 @@ const ALL_LEAF_ACTIONS = [
|
||||
export function useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel = false,
|
||||
onApplyLogFilter,
|
||||
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
@@ -65,9 +69,6 @@ export function useLogAttributeActions({
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const target = buildLogFilterTarget(
|
||||
context.fieldKeyPath,
|
||||
context.fieldValue,
|
||||
@@ -77,6 +78,29 @@ export function useLogAttributeActions({
|
||||
? target.filterInOperator
|
||||
: target.filterOutOperator;
|
||||
|
||||
// Non-explorer surfaces (infra monitoring, etc.) apply a ready v5
|
||||
// expression fragment to their own query.
|
||||
if (onApplyLogFilter) {
|
||||
const base = {
|
||||
filters: { items: [], op: 'AND' },
|
||||
} as unknown as IBuilderQuery;
|
||||
const nextFilters = getFilterQueryData(
|
||||
base,
|
||||
target,
|
||||
context.fieldValue,
|
||||
operator,
|
||||
).filters ?? { items: [], op: 'AND' };
|
||||
const { expression } = convertFiltersToExpression(nextFilters);
|
||||
if (expression) {
|
||||
onApplyLogFilter(expression);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
@@ -99,6 +123,7 @@ export function useLogAttributeActions({
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -179,20 +204,25 @@ export function useLogAttributeActions({
|
||||
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.isRestricted;
|
||||
|
||||
// The using surface must provide an apply path.
|
||||
const canApplyFilter = !!handleChangeSelectedView || !!onApplyLogFilter;
|
||||
|
||||
return [
|
||||
{
|
||||
key: LogDetailsAction.FILTER_IN,
|
||||
label: 'Filter for value',
|
||||
icon: <CirclePlus size={12} />,
|
||||
onClick: (context): void => filterFor(context, true),
|
||||
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!canApplyFilter || isRestricted(fieldKeyPath),
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.FILTER_OUT,
|
||||
label: 'Filter out value',
|
||||
icon: <CircleMinus size={12} />,
|
||||
onClick: (context): void => filterFor(context, false),
|
||||
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!canApplyFilter || isRestricted(fieldKeyPath),
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.GROUP_BY,
|
||||
@@ -200,8 +230,10 @@ export function useLogAttributeActions({
|
||||
icon: <Layers size={12} />,
|
||||
onClick: groupBy,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView ||
|
||||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.groupBySupported || isOldExplorerOrLive,
|
||||
.groupBySupported ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.REPLACE_FILTER,
|
||||
@@ -209,7 +241,9 @@ export function useLogAttributeActions({
|
||||
icon: <RefreshCw size={12} />,
|
||||
onClick: replaceFilter,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
|
||||
!handleChangeSelectedView ||
|
||||
isRestricted(fieldKeyPath) ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
@@ -218,6 +252,8 @@ export function useLogAttributeActions({
|
||||
replaceFilter,
|
||||
isBodyJsonQueryEnabled,
|
||||
isOldExplorerOrLive,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
]);
|
||||
|
||||
const visibleActions = useMemo<VisibleActionsConfig>(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.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
|
||||
);
|
||||
}
|
||||
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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%)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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;
|
||||
}
|
||||
181
frontend/src/lib/uPlotV2/components/Tooltip/HeatmapTooltip.tsx
Normal file
181
frontend/src/lib/uPlotV2/components/Tooltip/HeatmapTooltip.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import HeatmapTooltip from '../HeatmapTooltip';
|
||||
|
||||
const BOUNDS = [100, 500, 1000, 2500];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
|
||||
const STEP = 300;
|
||||
const PLOT_SIZE = 500;
|
||||
const ROW_COUNT = BOUNDS.length + 1;
|
||||
|
||||
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
|
||||
const HOVERED_ROW = 2;
|
||||
|
||||
function seriesFor(
|
||||
group: string,
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `service.name=${group}`,
|
||||
labels: [{ key: 'service.name', value: group }],
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const GROUPED: HeatmapSeries[] = [
|
||||
seriesFor('checkout', [355, 300]),
|
||||
seriesFor('frontend', [86, 80]),
|
||||
seriesFor('cart', [14, 10]),
|
||||
seriesFor('payments', [0, 0]),
|
||||
];
|
||||
|
||||
/** Grid counts, matching what the renderer would have been handed. */
|
||||
function gridData(rowTotals: number[]): uPlot.AlignedData {
|
||||
return [
|
||||
TIMESTAMPS,
|
||||
...Array.from({ length: ROW_COUNT }, (_, row) => [
|
||||
rowTotals[row] ?? row * 40,
|
||||
rowTotals[row] ?? row * 40,
|
||||
]),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
|
||||
const ROW_TOTALS = [10, 269, 455, 92, 2];
|
||||
|
||||
function createFakePlot(): uPlot {
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
// Aim the cursor at the middle of the hovered row, first column.
|
||||
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
|
||||
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
|
||||
|
||||
return {
|
||||
data: gridData(ROW_TOTALS),
|
||||
cursor: { left: PLOT_SIZE * 0.25, top },
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<HeatmapTooltip
|
||||
id="panel-1"
|
||||
uPlotInstance={createFakePlot()}
|
||||
dataIndexes={[]}
|
||||
seriesIndex={null}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
viaSync={false}
|
||||
yAxis={Y_AXIS}
|
||||
step={STEP}
|
||||
series={GROUPED}
|
||||
visibleGroups={GROUPED.map((entry) => entry.label)}
|
||||
groupColor="#fcfdbf"
|
||||
yAxisUnit="ms"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeatmapTooltip — cell identity', () => {
|
||||
it('heads with the time span the column covers, not a single instant', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the hovered bucket and its count', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
|
||||
'500 ms – 1 s',
|
||||
);
|
||||
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
|
||||
});
|
||||
|
||||
it('marks the surface as pinned so the border picks up the ring', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the cell identity from the block below it', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a footer when the panel supplies one', () => {
|
||||
renderTooltip({
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
|
||||
});
|
||||
|
||||
it('tells the footer when the tooltip is pinned', () => {
|
||||
renderTooltip({
|
||||
isPinned: true,
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
|
||||
});
|
||||
|
||||
it('renders nothing when the cursor is off the plot', () => {
|
||||
const plot = createFakePlot();
|
||||
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
|
||||
|
||||
const { container } = renderTooltip({ uPlotInstance: plot });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
it('breaks the cell down by group instead of showing neighbours', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('heatmap-tooltip-contribution'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-buckets'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('heads the breakdown with the groupBy key', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each row by value alone and orders by contribution', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
expect(rows[0]).toContain('checkout');
|
||||
expect(rows[0]).toContain('355');
|
||||
expect(rows[1]).toContain('frontend');
|
||||
expect(rows[2]).toContain('cart');
|
||||
});
|
||||
|
||||
it('shows each group"s share of the cell', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
// 355 / 455 = 78%, 86 / 455 = 19%, 14 / 455 = 3.1%
|
||||
expect(rows[0]).toHaveTextContent('78%');
|
||||
expect(rows[1]).toHaveTextContent('19%');
|
||||
expect(rows[2]).toHaveTextContent('3.1%');
|
||||
});
|
||||
|
||||
it('still lists a group that contributed nothing', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
expect(rows).toHaveLength(GROUPED.length);
|
||||
expect(rows[3]).toHaveTextContent('payments');
|
||||
expect(rows[3]).toHaveTextContent('0.0%');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['service.name=checkout'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the active filter', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'service.name = checkout',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — no grouping', () => {
|
||||
const ungrouped = {
|
||||
series: [{ label: '', points: GROUPED[0].points }],
|
||||
visibleGroups: [''],
|
||||
};
|
||||
|
||||
it('shows neighbouring buckets, highest first', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
// Two buckets either side of 500ms – 1s, reading down the y axis.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0]).toContain('> 2.5 s');
|
||||
expect(rows[2]).toContain('500 ms – 1 s');
|
||||
expect(rows[4]).toContain('≤ 100 ms');
|
||||
});
|
||||
|
||||
it('marks the hovered bucket among its neighbours', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const hovered = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.filter((row) => row.dataset.hovered === 'true');
|
||||
|
||||
expect(hovered).toHaveLength(1);
|
||||
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
|
||||
});
|
||||
|
||||
it('never breaks down a single series', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
/** Rows shown either side of the hovered one. */
|
||||
const NEIGHBOUR_SPAN = 2;
|
||||
/** Below this share a percentage needs a decimal to stay informative. */
|
||||
const PERCENT_DECIMAL_THRESHOLD = 10;
|
||||
/** Below this, the header needs seconds to distinguish columns. */
|
||||
const SUB_MINUTE_STEP = 60;
|
||||
|
||||
export const NO_DATA_LABEL = 'no data';
|
||||
|
||||
/**
|
||||
* Which question the second block answers. A cell summed across several groups begs
|
||||
* "which group?"; a cell that is already one series begs "how does this bucket
|
||||
* compare with its neighbours?".
|
||||
*/
|
||||
export enum HeatmapTooltipBody {
|
||||
Buckets = 'buckets',
|
||||
Contribution = 'contribution',
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
label: string;
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Share of the cell's total, 0..100. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
// One enabled group contributes the whole cell, so there is nothing to break
|
||||
// down — whether the query is ungrouped or the legend has isolated a group.
|
||||
return visibleCount > 1
|
||||
? HeatmapTooltipBody.Contribution
|
||||
: HeatmapTooltipBody.Buckets;
|
||||
}
|
||||
|
||||
/** A cell is an interval, so a single instant would misreport which observations
|
||||
* it contains. The date is left to the x axis directly below. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
timezone,
|
||||
}: {
|
||||
/** Column start, in seconds. */
|
||||
start: number;
|
||||
/** Column width, in seconds. */
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const format =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
return `${from.format(format)} → ${to.format(format)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
export function formatBucketLabel({
|
||||
yAxis,
|
||||
row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): string {
|
||||
const bucket = yAxis.rows[row];
|
||||
if (!bucket) {
|
||||
return '';
|
||||
}
|
||||
return formatRowLabel(bucket, (value) =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCount(count: number | null): string {
|
||||
return count === null ? NO_DATA_LABEL : count.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatPercent(percent: number): string {
|
||||
return percent >= PERCENT_DECIMAL_THRESHOLD
|
||||
? `${Math.round(percent)}%`
|
||||
: `${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Names the group the grid is currently isolated to. */
|
||||
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
|
||||
if (!series) {
|
||||
return '';
|
||||
}
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels
|
||||
.map((label) => `${label.key} = ${label.value}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** The `groupBy` keys the breakdown is by. */
|
||||
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
|
||||
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
|
||||
return keys.join(', ');
|
||||
}
|
||||
|
||||
function formatSeriesValue(series: HeatmapSeries): string {
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels.map((label) => label.value).join(', ');
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
export function buildBucketRows({
|
||||
counts,
|
||||
yAxis,
|
||||
row,
|
||||
column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
/** Row-major, as the renderer draws them. */
|
||||
counts: Array<ArrayLike<number | null> | undefined>;
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
column: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): HeatmapBucketRow[] {
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const rows: HeatmapBucketRow[] = [];
|
||||
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
|
||||
const index = row + offset;
|
||||
const bucket = yAxis.rows[index];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest first. Groups that contributed nothing are still listed — that is an
|
||||
* answer, and dropping the row makes the list look truncated.
|
||||
*/
|
||||
export function buildContributionRows({
|
||||
series,
|
||||
timestamp,
|
||||
row,
|
||||
color,
|
||||
}: {
|
||||
/** Only the groups the legend has enabled — they are what the cell sums. */
|
||||
series: HeatmapSeries[];
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
const counts = series.map((entry) => {
|
||||
const point = entry.points.find((item) => item.timestamp === timestamp);
|
||||
// Absent or null contributed nothing to the sum, which is what this breaks down.
|
||||
return point?.counts[row] ?? 0;
|
||||
});
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return series
|
||||
.map((entry, index) => ({
|
||||
label: formatSeriesValue(entry),
|
||||
color,
|
||||
count: counts[index],
|
||||
percent: total > 0 ? (counts[index] / total) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,21 @@ 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
|
||||
|
||||
@@ -157,6 +157,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
@@ -188,6 +189,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (gap !== undefined) {
|
||||
axisConfig.gap = gap;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ 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;
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface AxisProps {
|
||||
size?: number;
|
||||
};
|
||||
values?: uPlot.Axis.Values;
|
||||
splits?: uPlot.Axis.Splits;
|
||||
gap?: number;
|
||||
size?: uPlot.Axis.Size;
|
||||
formatValue?: (v: number) => string;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
canUseLogAxis,
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnIndex,
|
||||
resolveHeatmapYAxis,
|
||||
resolveRowIndex,
|
||||
} from '../geometry';
|
||||
import { HeatmapAxisScale } from '../types';
|
||||
|
||||
const BOUNDS = [128, 256, 1024, 4096];
|
||||
|
||||
describe('canUseLogAxis', () => {
|
||||
it('accepts strictly positive bounds', () => {
|
||||
expect(canUseLogAxis(BOUNDS)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a zero or negative bound', () => {
|
||||
expect(canUseLogAxis([0, 128])).toBe(false);
|
||||
expect(canUseLogAxis([-1, 128])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty bounds', () => {
|
||||
expect(canUseLogAxis([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis', () => {
|
||||
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(BOUNDS.length + 1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
upper: 128,
|
||||
isUnderflow: true,
|
||||
isOverflow: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
|
||||
expect(rows[4]).toMatchObject({
|
||||
lower: 4096,
|
||||
isOverflow: true,
|
||||
isUnderflow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes one edge per row boundary, ascending', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(edges).toHaveLength(rows.length + 1);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('places bounds in log space so row heights are log-proportional', () => {
|
||||
const { splits, min, max } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
|
||||
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
|
||||
});
|
||||
|
||||
it('keeps bounds in value space on a linear axis', () => {
|
||||
const { splits, min } = resolveHeatmapYAxis(
|
||||
[10, 20, 30],
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([10, 20, 30]);
|
||||
// Mean gap is 10, and the underflow edge never crosses zero.
|
||||
expect(min).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts and de-duplicates bounds', () => {
|
||||
const { rows, splits } = resolveHeatmapYAxis(
|
||||
[256, 128, 256, Number.NaN],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a single bound an underflow and an overflow row', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].isUnderflow).toBe(true);
|
||||
expect(rows[1].isOverflow).toBe(true);
|
||||
expect(edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('degrades to an empty axis with no bounds', () => {
|
||||
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
|
||||
const { overflowSplit, edges } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
// A full row above the last boundary tick, so the two labels cannot collide.
|
||||
expect(overflowSplit).toBe(edges[edges.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowIndex', () => {
|
||||
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
|
||||
it('finds the row containing a value', () => {
|
||||
expect(resolveRowIndex(edges, 200)).toBe(1);
|
||||
expect(resolveRowIndex(edges, 2000)).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns a boundary to the row it opens', () => {
|
||||
expect(resolveRowIndex(edges, 256)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the last row on the top edge', () => {
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
|
||||
edges.length - 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null outside the grid', () => {
|
||||
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without at least one row', () => {
|
||||
expect(resolveRowIndex([5], 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnIndex', () => {
|
||||
const timestamps = [100, 160, 220, 280];
|
||||
const step = 60;
|
||||
|
||||
it('resolves by containment, not proximity', () => {
|
||||
// 155 is nearer to 160, but the observations at 155 belong to column 0.
|
||||
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
|
||||
});
|
||||
|
||||
it('includes the column start and excludes its end', () => {
|
||||
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
|
||||
});
|
||||
|
||||
it('covers the trailing column using the step, not the next timestamp', () => {
|
||||
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
|
||||
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null before the first column', () => {
|
||||
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no columns', () => {
|
||||
expect(resolveColumnIndex([], 100, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the last column open when the step is unknown', () => {
|
||||
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRowLabel', () => {
|
||||
const format = (value: number): string => `${value}ms`;
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
it('labels the underflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
|
||||
});
|
||||
|
||||
it('labels the overflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
|
||||
});
|
||||
|
||||
it('labels an interior row as a range', () => {
|
||||
expect(formatRowLabel(rows[1], format)).toBe('128ms – 256ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimateAxisSplits', () => {
|
||||
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const domain = { min: 0, max: 10 };
|
||||
|
||||
it('keeps every tick when they all fit', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
|
||||
it('thins to whatever fits at the available height', () => {
|
||||
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
|
||||
).toStrictEqual([0, 2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 40,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect(thinned[thinned.length - 1]).toBe(10);
|
||||
});
|
||||
|
||||
it('returns ascending positions', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 60,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
|
||||
});
|
||||
|
||||
it('thins by pixel distance, not index, so uneven rows are handled', () => {
|
||||
// Three boundaries bunched at the bottom of a wide linear domain: only the
|
||||
// first and the far-away last are far enough apart to both get labels.
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits: [1, 2, 3, 1000],
|
||||
min: 0,
|
||||
max: 1000,
|
||||
plotHeight: 200,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual([3, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the tick set alone when it cannot measure', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits,
|
||||
min: 5,
|
||||
max: 5,
|
||||
plotHeight: 400,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
// The OTel SDK default explicit bucket boundaries, which start at zero.
|
||||
const OTEL = [
|
||||
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
|
||||
];
|
||||
// Clock skew in ms — a logs/traces field that straddles zero.
|
||||
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
|
||||
|
||||
const PLOT_HEIGHT = 250;
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
function rowHeights(bounds: number[]): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
|
||||
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
const span = edges[edges.length - 1] - edges[0];
|
||||
const heights = edges
|
||||
.slice(1)
|
||||
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
|
||||
return Math.min(...heights);
|
||||
}
|
||||
|
||||
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
|
||||
|
||||
// A linear fallback would leave the boundaries untransformed.
|
||||
expect(splits).not.toStrictEqual([0, 5, 10]);
|
||||
});
|
||||
|
||||
it('gives every row a usable height for the OTel default boundaries', () => {
|
||||
// Linear squeezes the 0–100ms buckets — where the data is — under a pixel.
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Log)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
|
||||
const heights = rowHeights(OTEL);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
|
||||
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
|
||||
|
||||
// One axis unit — the same space a decade gets above the threshold.
|
||||
expect(heights[nearZero]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('keeps negative boundaries ascending', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('round-trips a boundary back to its bucket value', () => {
|
||||
const { splits, toBucketValue } = resolveHeatmapYAxis(
|
||||
SKEW,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(
|
||||
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
|
||||
).toStrictEqual(SKEW);
|
||||
});
|
||||
|
||||
it('derives the linear threshold from the smallest non-zero boundary', () => {
|
||||
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
|
||||
const { edges, rows } = resolveHeatmapYAxis(
|
||||
[-100, -10, 0, 10, 100],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
const crossing = rows.findIndex(
|
||||
(row) => row.lower === -10 && row.upper === 0,
|
||||
);
|
||||
|
||||
expect(edges[crossing]).toBeCloseTo(-1, 6);
|
||||
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('leaves an all-positive layout on a plain log axis', () => {
|
||||
const { splits } = resolveHeatmapYAxis(
|
||||
[128, 256, 1024],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('falls back to linear when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
198
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
198
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
297
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
297
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
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();
|
||||
}
|
||||
99
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
99
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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 };
|
||||
}
|
||||
170
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
170
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
118
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
118
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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';
|
||||
}
|
||||
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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();
|
||||
}
|
||||
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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();
|
||||
}
|
||||
113
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
113
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end
|
||||
return &operations, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc string, start, end time.Time) (string, error) {
|
||||
func (r *ClickHouseReader) buildResourceSubQuery(ctx context.Context, orgID valuer.UUID, tags []model.TagQueryParam, svc string, start, end time.Time) (string, error) {
|
||||
// assuming all will be resource attributes.
|
||||
// and resource attributes are string for traces
|
||||
filterSet := v3.FilterSet{}
|
||||
@@ -387,7 +387,8 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
|
||||
&filterSet,
|
||||
[]v3.AttributeKey{},
|
||||
v3.AttributeKey{},
|
||||
false)
|
||||
false,
|
||||
r.fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID)))
|
||||
if err != nil {
|
||||
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
|
||||
return "", err
|
||||
@@ -395,7 +396,7 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
|
||||
return resourceSubQuery, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, orgID valuer.UUID, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
@@ -467,7 +468,7 @@ func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.G
|
||||
clickhouse.Named("names", ops),
|
||||
)
|
||||
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(queryParams.Tags, svc, *queryParams.Start, *queryParams.End)
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(ctx, orgID, queryParams.Tags, svc, *queryParams.Start, *queryParams.End)
|
||||
if err != nil {
|
||||
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
|
||||
return
|
||||
@@ -703,9 +704,9 @@ func addExistsOperator(item model.TagQuery, tagMapType string, not bool) (string
|
||||
return fmt.Sprintf(" AND %s (%s)", notStr, strings.Join(tagOperatorPair, " OR ")), args
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
|
||||
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, orgID valuer.UUID, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
|
||||
// Step 1: Get top operations for the given service
|
||||
topOps, err := r.GetTopOperations(ctx, queryParams)
|
||||
topOps, err := r.GetTopOperations(ctx, orgID, queryParams)
|
||||
if err != nil {
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "Error in getting Top Operations")
|
||||
}
|
||||
@@ -757,7 +758,7 @@ func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryPar
|
||||
return &filtered, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, orgID valuer.UUID, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
@@ -787,7 +788,7 @@ func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *mo
|
||||
r.TraceDB, r.traceTableName,
|
||||
)
|
||||
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(queryParams.Tags, queryParams.ServiceName, *queryParams.Start, *queryParams.End)
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(ctx, orgID, queryParams.Tags, queryParams.ServiceName, *queryParams.Start, *queryParams.End)
|
||||
if err != nil {
|
||||
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing sql query")}
|
||||
@@ -858,7 +859,7 @@ func (r *ClickHouseReader) GetUsage(ctx context.Context, queryParams *model.GetU
|
||||
return &usageItems, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
|
||||
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, orgID valuer.UUID, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
@@ -895,7 +896,7 @@ func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *
|
||||
)
|
||||
|
||||
tags := createTagQueryFromTagQueryParams(queryParams.Tags)
|
||||
filterQuery, filterArgs := services.BuildServiceMapQuery(tags)
|
||||
filterQuery, filterArgs := services.BuildServiceMapQuery(tags, r.fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID)))
|
||||
query += filterQuery + " GROUP BY src, dest;"
|
||||
args = append(args, filterArgs...)
|
||||
|
||||
|
||||
@@ -1128,13 +1128,19 @@ func (aH *APIHandler) registerEvent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getTopOperations(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetTopOperationsRequest(r)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetTopOperations(r.Context(), query)
|
||||
result, apiErr := aH.reader.GetTopOperations(r.Context(), orgID, query)
|
||||
|
||||
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
|
||||
return
|
||||
@@ -1145,13 +1151,20 @@ func (aH *APIHandler) getTopOperations(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getEntryPointOps(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetTopOperationsRequest(r)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetEntryPointOperations(r.Context(), query)
|
||||
result, apiErr := aH.reader.GetEntryPointOperations(r.Context(), orgID, query)
|
||||
if apiErr != nil {
|
||||
render.Error(w, apiErr)
|
||||
return
|
||||
@@ -1226,12 +1239,19 @@ func (aH *APIHandler) getServicesTopLevelOps(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetServicesRequest(r)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetServices(r.Context(), query)
|
||||
result, apiErr := aH.reader.GetServices(r.Context(), orgID, query)
|
||||
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
@@ -1240,13 +1260,19 @@ func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) dependencyGraph(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetServicesRequest(r)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := aH.reader.GetDependencyGraph(r.Context(), query)
|
||||
result, err := aH.reader.GetDependencyGraph(r.Context(), orgID, query)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
|
||||
}
|
||||
|
||||
// build the where clause for resource table
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false)
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -475,7 +475,7 @@ func buildLogsLiveTailQuery(mq *v3.BuilderQuery) (string, error) {
|
||||
}
|
||||
|
||||
// no values for bucket start and end
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, 0, 0, mq.Filters, mq.GroupBy, mq.AggregateAttribute, true)
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, 0, 0, mq.Filters, mq.GroupBy, mq.AggregateAttribute, true, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var resourceLogOperators = map[v3.FilterOperator]string{
|
||||
@@ -30,22 +33,49 @@ var resourceLogOperators = map[v3.FilterOperator]string{
|
||||
}
|
||||
|
||||
// buildResourceFilter builds a clickhouse filter string for resource labels
|
||||
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}) string {
|
||||
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}, members []string) string {
|
||||
// for all operators except contains and like
|
||||
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, '%s')", key)
|
||||
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
if len(members) > 1 {
|
||||
values := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
values = append(values, fmt.Sprintf("NULLIF(simpleJSONExtractString(labels, %s), '')", querybuilder.ClickHouseStringLiteral(member)))
|
||||
}
|
||||
searchKey = "COALESCE(" + strings.Join(values, ", ") + ", '')"
|
||||
}
|
||||
|
||||
// for contains and like it will be case insensitive
|
||||
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), '%s')", key)
|
||||
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
if len(members) > 1 {
|
||||
lowerSearchKey = "lower(" + searchKey + ")"
|
||||
}
|
||||
|
||||
chFmtVal := utils.ClickHouseFormattedValue(value)
|
||||
|
||||
lowerValue := strings.ToLower(fmt.Sprintf("%s", value))
|
||||
|
||||
switch op {
|
||||
case v3.FilterOperatorExists:
|
||||
return fmt.Sprintf("simpleJSONHas(labels, '%s')", key)
|
||||
case v3.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("not simpleJSONHas(labels, '%s')", key)
|
||||
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
|
||||
exists := op == v3.FilterOperatorExists
|
||||
if len(members) == 1 {
|
||||
if exists {
|
||||
return fmt.Sprintf("simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
}
|
||||
return fmt.Sprintf("not simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
}
|
||||
presence := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if exists {
|
||||
presence = append(presence, fmt.Sprintf("simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(member)))
|
||||
} else {
|
||||
presence = append(presence, fmt.Sprintf("not simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(member)))
|
||||
}
|
||||
}
|
||||
separator := " OR "
|
||||
if !exists {
|
||||
separator = " AND "
|
||||
}
|
||||
return "(" + strings.Join(presence, separator) + ")"
|
||||
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
|
||||
return fmt.Sprintf(logsOp, searchKey, chFmtVal)
|
||||
case v3.FilterOperatorContains, v3.FilterOperatorNotContains:
|
||||
@@ -93,9 +123,10 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
|
||||
|
||||
// if there are no values to filter on, return an empty string
|
||||
if len(values) > 0 {
|
||||
escapedKey := utils.QuoteEscapedStringForContains(key, true)
|
||||
for _, v := range values {
|
||||
value := utils.QuoteEscapedStringForContains(v, true)
|
||||
conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, key, value))
|
||||
conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, escapedKey, value))
|
||||
}
|
||||
return "(" + strings.Join(conditions, separator) + ")"
|
||||
}
|
||||
@@ -109,8 +140,34 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
|
||||
// for like/contains we will use lower index
|
||||
// we can use lower index for =, in etc but it's difficult to do it for !=, NIN etc
|
||||
// if as x != "ABC" we cannot predict something like "not lower(labels) like '%%x%%abc%%'". It has it be "not lower(labels) like '%%x%%ABC%%'"
|
||||
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}) string {
|
||||
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}, members []string) string {
|
||||
if len(members) > 1 {
|
||||
// A negated hint would drop rows where another member holds the value.
|
||||
switch op {
|
||||
case v3.FilterOperatorNotEqual,
|
||||
v3.FilterOperatorNotLike,
|
||||
v3.FilterOperatorNotILike,
|
||||
v3.FilterOperatorNotContains,
|
||||
v3.FilterOperatorNotExists,
|
||||
v3.FilterOperatorNotRegex,
|
||||
v3.FilterOperatorNotIn:
|
||||
return ""
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if condition := buildResourceIndexFilter(member, op, value, []string{member}); condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "(" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
// not using clickhouseFormattedValue as we don't wan't the quotes
|
||||
escapedKey := utils.QuoteEscapedStringForContains(key, true)
|
||||
strVal := fmt.Sprintf("%s", value)
|
||||
fmtValEscapedForContains := utils.QuoteEscapedStringForContains(strVal, true)
|
||||
fmtValEscapedForContainsLower := strings.ToLower(fmtValEscapedForContains)
|
||||
@@ -119,36 +176,36 @@ func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{
|
||||
// add index filters
|
||||
switch op {
|
||||
case v3.FilterOperatorEqual:
|
||||
return fmt.Sprintf("labels like '%%%s\":\"%s%%'", key, fmtValEscapedForContains)
|
||||
return fmt.Sprintf("labels like '%%%s\":\"%s%%'", escapedKey, fmtValEscapedForContains)
|
||||
case v3.FilterOperatorNotEqual:
|
||||
return fmt.Sprintf("labels not like '%%%s\":\"%s%%'", key, fmtValEscapedForContains)
|
||||
return fmt.Sprintf("labels not like '%%%s\":\"%s%%'", escapedKey, fmtValEscapedForContains)
|
||||
case v3.FilterOperatorLike, v3.FilterOperatorILike:
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", key, fmtValEscapedLower)
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", escapedKey, fmtValEscapedLower)
|
||||
case v3.FilterOperatorNotLike, v3.FilterOperatorNotILike:
|
||||
// cannot apply not contains x%y as y can be somewhere else
|
||||
return ""
|
||||
case v3.FilterOperatorContains:
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", key, fmtValEscapedForContainsLower)
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", escapedKey, fmtValEscapedForContainsLower)
|
||||
case v3.FilterOperatorNotContains:
|
||||
// cannot apply not contains x%y as y can be somewhere else
|
||||
return ""
|
||||
case v3.FilterOperatorExists:
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%'", key)
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%'", escapedKey)
|
||||
case v3.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("lower(labels) not like '%%%s%%'", key)
|
||||
return fmt.Sprintf("lower(labels) not like '%%%s%%'", escapedKey)
|
||||
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
|
||||
// don't try to do anything for regex.
|
||||
return ""
|
||||
case v3.FilterOperatorIn, v3.FilterOperatorNotIn:
|
||||
return buildIndexFilterForInOperator(key, op, value)
|
||||
default:
|
||||
return fmt.Sprintf("labels like '%%%s%%'", key)
|
||||
return fmt.Sprintf("labels like '%%%s%%'", escapedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// buildResourceFiltersFromFilterItems builds a list of clickhouse filter strings for resource labels from a FilterSet.
|
||||
// It skips any filter items that are not resource attributes and checks that the operator is supported and the data type is correct.
|
||||
func buildResourceFiltersFromFilterItems(fs *v3.FilterSet) ([]string, error) {
|
||||
func buildResourceFiltersFromFilterItems(fs *v3.FilterSet, resolveSemconvFamilies bool) ([]string, error) {
|
||||
var conditions []string
|
||||
if fs == nil || len(fs.Items) == 0 {
|
||||
return nil, nil
|
||||
@@ -182,12 +239,20 @@ func buildResourceFiltersFromFilterItems(fs *v3.FilterSet) ([]string, error) {
|
||||
}
|
||||
|
||||
if logsOp, ok := resourceLogOperators[op]; ok {
|
||||
members := []string{keyName}
|
||||
if resolveSemconvFamilies {
|
||||
members = semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: keyName,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
})
|
||||
}
|
||||
// the filter
|
||||
if resourceFilter := buildResourceFilter(logsOp, keyName, op, value); resourceFilter != "" {
|
||||
if resourceFilter := buildResourceFilter(logsOp, keyName, op, value, members); resourceFilter != "" {
|
||||
conditions = append(conditions, resourceFilter)
|
||||
}
|
||||
// the additional filter for better usage of the index
|
||||
if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value); resourceIndexFilter != "" {
|
||||
if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value, members); resourceIndexFilter != "" {
|
||||
conditions = append(conditions, resourceIndexFilter)
|
||||
}
|
||||
} else {
|
||||
@@ -219,12 +284,12 @@ func buildResourceFiltersFromAggregateAttribute(aggregateAttribute v3.AttributeK
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildResourceSubQuery(dbName, tableName string, bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey, isLiveTail bool) (string, error) {
|
||||
func BuildResourceSubQuery(dbName, tableName string, bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey, isLiveTail bool, resolveSemconvFamilies bool) (string, error) {
|
||||
|
||||
// BUILD THE WHERE CLAUSE
|
||||
var conditions []string
|
||||
// only add the resource attributes to the filters here
|
||||
rs, err := buildResourceFiltersFromFilterItems(fs)
|
||||
rs, err := buildResourceFiltersFromFilterItems(fs, resolveSemconvFamilies)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_buildResourceFilter(t *testing.T) {
|
||||
@@ -88,7 +89,7 @@ func Test_buildResourceFilter(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value); got != tt.want {
|
||||
if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value, []string{tt.args.key}); got != tt.want {
|
||||
t.Errorf("buildResourceFilter() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -282,7 +283,7 @@ func Test_buildResourceIndexFilter(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value); got != tt.want {
|
||||
if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value, []string{tt.args.key}); got != tt.want {
|
||||
t.Errorf("buildResourceIndexFilter() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -379,7 +380,7 @@ func Test_buildResourceFiltersFromFilterItems(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildResourceFiltersFromFilterItems(tt.args.fs)
|
||||
got, err := buildResourceFiltersFromFilterItems(tt.args.fs, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildResourceFiltersFromFilterItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -541,7 +542,7 @@ func Test_buildResourceSubQuery(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := BuildResourceSubQuery("signoz_logs", "distributed_logs_v2_resource", tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute, false)
|
||||
got, err := BuildResourceSubQuery("signoz_logs", "distributed_logs_v2_resource", tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute, false, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildResourceSubQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -552,3 +553,58 @@ func Test_buildResourceSubQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_buildResourceFilterFamily(t *testing.T) {
|
||||
members := []string{"deployment.environment.name", "deployment.environment"}
|
||||
|
||||
require.Equal(t,
|
||||
"COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = 'production'",
|
||||
buildResourceFilter("=", "deployment.environment.name", v3.FilterOperatorEqual, "production", members))
|
||||
|
||||
require.Equal(t,
|
||||
"COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') != 'production'",
|
||||
buildResourceFilter("!=", "deployment.environment.name", v3.FilterOperatorNotEqual, "production", members))
|
||||
|
||||
require.Equal(t,
|
||||
"(simpleJSONHas(labels, 'deployment.environment.name') OR simpleJSONHas(labels, 'deployment.environment'))",
|
||||
buildResourceFilter("", "deployment.environment.name", v3.FilterOperatorExists, nil, members))
|
||||
|
||||
require.Equal(t,
|
||||
"(not simpleJSONHas(labels, 'deployment.environment.name') AND not simpleJSONHas(labels, 'deployment.environment'))",
|
||||
buildResourceFilter("", "deployment.environment.name", v3.FilterOperatorNotExists, nil, members))
|
||||
}
|
||||
|
||||
func Test_buildResourceIndexFilterFamily(t *testing.T) {
|
||||
members := []string{"deployment.environment.name", "deployment.environment"}
|
||||
|
||||
require.Equal(t,
|
||||
`(labels like '%deployment.environment.name":"production%' OR labels like '%deployment.environment":"production%')`,
|
||||
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorEqual, "production", members))
|
||||
|
||||
require.Equal(t, "",
|
||||
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorNotEqual, "production", members))
|
||||
require.Equal(t, "",
|
||||
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorNotIn, []interface{}{"production"}, members))
|
||||
}
|
||||
|
||||
func TestBuildResourceSubQueryFamily(t *testing.T) {
|
||||
fs := &v3.FilterSet{Items: []v3.FilterItem{{
|
||||
Key: v3.AttributeKey{
|
||||
Key: "deployment.environment.name",
|
||||
DataType: v3.AttributeKeyDataTypeString,
|
||||
Type: v3.AttributeKeyTypeResource,
|
||||
},
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: "production",
|
||||
}}}
|
||||
|
||||
familyOn, err := BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", 1, 2, fs, nil, v3.AttributeKey{}, false, true)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, familyOn, "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = 'production'")
|
||||
require.Contains(t, familyOn, `(labels like '%deployment.environment.name":"production%' OR labels like '%deployment.environment":"production%')`)
|
||||
|
||||
familyOff, err := BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", 1, 2, fs, nil, v3.AttributeKey{}, false, false)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, familyOff, "simpleJSONExtractString(labels, 'deployment.environment.name') = 'production'")
|
||||
require.NotContains(t, familyOff, "COALESCE")
|
||||
}
|
||||
|
||||
@@ -6,17 +6,25 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var (
|
||||
columns = map[string]struct{}{
|
||||
"deployment_environment": {},
|
||||
"k8s_cluster_name": {},
|
||||
"k8s_namespace_name": {},
|
||||
func BuildServiceMapQuery(tags []model.TagQuery, resolveSemconvFamilies bool) (string, []interface{}) {
|
||||
columns := map[string]string{
|
||||
"deployment_environment": "deployment_environment",
|
||||
"k8s_cluster_name": "k8s_cluster_name",
|
||||
"k8s_namespace_name": "k8s_namespace_name",
|
||||
}
|
||||
if resolveSemconvFamilies {
|
||||
for _, member := range semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}) {
|
||||
columns[strings.ReplaceAll(member, ".", "_")] = "deployment_environment"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
|
||||
var filterQuery string
|
||||
var namedArgs []interface{}
|
||||
for _, tag := range tags {
|
||||
@@ -24,39 +32,40 @@ func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
|
||||
operator := tag.GetOperator()
|
||||
value := tag.GetValues()
|
||||
|
||||
if _, ok := columns[key]; !ok {
|
||||
column, ok := columns[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch operator {
|
||||
case model.InOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IN @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IN @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.NotInOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.EqualOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s = @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s = @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.NotEqualOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s != @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s != @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.ContainsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
|
||||
case model.NotContainsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
|
||||
case model.StartsWithOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
|
||||
case model.NotStartsWithOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
|
||||
case model.ExistsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", column)
|
||||
case model.NotExistsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NULL", key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NULL", column)
|
||||
}
|
||||
}
|
||||
return filterQuery, namedArgs
|
||||
|
||||
37
pkg/query-service/app/services/map_test.go
Normal file
37
pkg/query-service/app/services/map_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildServiceMapQueryFamily(t *testing.T) {
|
||||
newSpelling := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
|
||||
Key: "deployment.environment.name",
|
||||
StringValues: []string{"production"},
|
||||
Operator: model.EqualOperator,
|
||||
})}
|
||||
oldSpelling := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
|
||||
Key: "deployment.environment",
|
||||
StringValues: []string{"production"},
|
||||
Operator: model.EqualOperator,
|
||||
})}
|
||||
|
||||
query, args := BuildServiceMapQuery(newSpelling, true)
|
||||
require.Equal(t, " AND deployment_environment = @deployment_environment_name", query)
|
||||
require.Len(t, args, 1)
|
||||
|
||||
query, args = BuildServiceMapQuery(oldSpelling, true)
|
||||
require.Equal(t, " AND deployment_environment = @deployment_environment", query)
|
||||
require.Len(t, args, 1)
|
||||
|
||||
query, args = BuildServiceMapQuery(newSpelling, false)
|
||||
require.Equal(t, "", query)
|
||||
require.Empty(t, args)
|
||||
|
||||
query, args = BuildServiceMapQuery(oldSpelling, false)
|
||||
require.Equal(t, " AND deployment_environment = @deployment_environment", query)
|
||||
require.Len(t, args, 1)
|
||||
}
|
||||
@@ -282,7 +282,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
|
||||
filterSubQuery = filterSubQuery + " AND " + emptyValuesInGroupByFilter
|
||||
}
|
||||
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false)
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ type Reader interface {
|
||||
GetInstantQueryMetricsResult(ctx context.Context, query *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
|
||||
GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
|
||||
GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
|
||||
GetEntryPointOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)
|
||||
GetServices(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
|
||||
GetTopOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
|
||||
GetEntryPointOperations(ctx context.Context, orgID valuer.UUID, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)
|
||||
GetServices(ctx context.Context, orgID valuer.UUID, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
|
||||
GetTopOperations(ctx context.Context, orgID valuer.UUID, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
|
||||
GetUsage(ctx context.Context, query *model.GetUsageParams) (*[]model.UsageItem, error)
|
||||
GetServicesList(ctx context.Context) (*[]string, error)
|
||||
GetDependencyGraph(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
|
||||
GetDependencyGraph(ctx context.Context, orgID valuer.UUID, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
|
||||
|
||||
GetTTL(ctx context.Context, orgID string, ttlParams *retentiontypes.GetTTLParams) (*retentiontypes.GetTTLResponseItem, *model.ApiError)
|
||||
GetCustomRetentionTTL(ctx context.Context, orgID string) (*retentiontypes.GetCustomRetentionTTLResponse, error)
|
||||
|
||||
Reference in New Issue
Block a user