mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
323 lines
9.8 KiB
TypeScript
323 lines
9.8 KiB
TypeScript
import type {CSSProperties} from 'react';
|
|
import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import {mat3, vec2} from 'gl-matrix';
|
|
|
|
import {t} from 'sentry/locale';
|
|
import type {CanvasPoolManager} from 'sentry/utils/profiling/canvasScheduler';
|
|
import {useCanvasScheduler} from 'sentry/utils/profiling/canvasScheduler';
|
|
import type {CanvasView} from 'sentry/utils/profiling/canvasView';
|
|
import {useFlamegraphTheme} from 'sentry/utils/profiling/flamegraph/useFlamegraphTheme';
|
|
import type {FlamegraphCanvas} from 'sentry/utils/profiling/flamegraphCanvas';
|
|
import {FlamegraphChart as FlamegraphChartModel} from 'sentry/utils/profiling/flamegraphChart';
|
|
import {
|
|
getConfigViewTranslationBetweenVectors,
|
|
getPhysicalSpacePositionFromOffset,
|
|
transformMatrixBetweenRect,
|
|
} from 'sentry/utils/profiling/gl/utils';
|
|
import {FlamegraphChartRenderer} from 'sentry/utils/profiling/renderers/chartRenderer';
|
|
import type {Rect} from 'sentry/utils/profiling/speedscope';
|
|
import {useProfiles} from 'sentry/views/profiling/profilesProvider';
|
|
|
|
import {useCanvasScroll} from './interactions/useCanvasScroll';
|
|
import {useCanvasZoomOrScroll} from './interactions/useCanvasZoomOrScroll';
|
|
import {useInteractionViewCheckPoint} from './interactions/useInteractionViewCheckPoint';
|
|
import {useWheelCenterZoom} from './interactions/useWheelCenterZoom';
|
|
import {
|
|
CollapsibleTimelineLoadingIndicator,
|
|
CollapsibleTimelineMessage,
|
|
} from './collapsibleTimeline';
|
|
import {FlamegraphChartTooltip} from './flamegraphChartTooltip';
|
|
|
|
interface FlamegraphChartProps {
|
|
canvasBounds: Rect;
|
|
canvasPoolManager: CanvasPoolManager;
|
|
chart: FlamegraphChartModel | null;
|
|
chartCanvas: FlamegraphCanvas | null;
|
|
chartCanvasRef: HTMLCanvasElement | null;
|
|
chartView: CanvasView<FlamegraphChartModel> | null;
|
|
noMeasurementMessage: string | undefined;
|
|
setChartCanvasRef: (ref: HTMLCanvasElement | null) => void;
|
|
}
|
|
|
|
export function FlamegraphChart({
|
|
chart,
|
|
canvasPoolManager,
|
|
chartView,
|
|
chartCanvas,
|
|
chartCanvasRef,
|
|
setChartCanvasRef,
|
|
canvasBounds,
|
|
noMeasurementMessage,
|
|
}: FlamegraphChartProps) {
|
|
const profiles = useProfiles();
|
|
const scheduler = useCanvasScheduler(canvasPoolManager);
|
|
const theme = useFlamegraphTheme();
|
|
|
|
const [configSpaceCursor, setConfigSpaceCursor] = useState<vec2 | null>(null);
|
|
const [startInteractionVector, setStartInteractionVector] = useState<vec2 | null>(null);
|
|
const [lastInteraction, setLastInteraction] = useState<
|
|
'pan' | 'click' | 'zoom' | 'scroll' | 'select' | 'resize' | null
|
|
>(null);
|
|
|
|
const configSpaceCursorRef = useRef<vec2 | null>(null);
|
|
configSpaceCursorRef.current = configSpaceCursor;
|
|
|
|
const chartRenderer = useMemo(() => {
|
|
if (!chartCanvasRef || !chart) {
|
|
return null;
|
|
}
|
|
|
|
return new FlamegraphChartRenderer(chartCanvasRef, chart, theme);
|
|
}, [chartCanvasRef, chart, theme]);
|
|
|
|
const drawchart = useCallback(() => {
|
|
if (!chartCanvas || !chart || !chartView || !chartRenderer) {
|
|
return;
|
|
}
|
|
|
|
const configViewToPhysicalSpaceTransform = transformMatrixBetweenRect(
|
|
chartView.configView,
|
|
chartCanvas.physicalSpace
|
|
);
|
|
|
|
const offsetPhysicalSpace = chartCanvas.physicalSpace
|
|
// shrink the chart height by the padding to pad the top of chart
|
|
.withHeight(chartCanvas.physicalSpace.height - theme.SIZES.CHART_PX_PADDING);
|
|
|
|
const physicalSpaceToOffsetPhysicalSpaceTransform = transformMatrixBetweenRect(
|
|
chartCanvas.physicalSpace,
|
|
offsetPhysicalSpace
|
|
);
|
|
|
|
const configToPhysicalSpace = mat3.create();
|
|
mat3.multiply(
|
|
configToPhysicalSpace,
|
|
physicalSpaceToOffsetPhysicalSpaceTransform,
|
|
configViewToPhysicalSpaceTransform
|
|
);
|
|
|
|
mat3.multiply(
|
|
configToPhysicalSpace,
|
|
transformMatrixBetweenRect(chartView.configView, chartCanvas.physicalSpace),
|
|
chartView.configSpaceTransform
|
|
);
|
|
|
|
mat3.multiply(
|
|
configToPhysicalSpace,
|
|
chartCanvas.physicalSpace.invertYTransform(),
|
|
configToPhysicalSpace
|
|
);
|
|
|
|
chartRenderer.draw(
|
|
chartView.toOriginConfigView(chartView.configView),
|
|
configToPhysicalSpace, // this
|
|
chartView.fromTransformedConfigView(chartCanvas.logicalSpace),
|
|
configSpaceCursorRef
|
|
);
|
|
}, [chart, chartCanvas, chartRenderer, chartView, theme]);
|
|
|
|
useEffect(() => {
|
|
drawchart();
|
|
}, [drawchart, configSpaceCursor]);
|
|
|
|
useEffect(() => {
|
|
scheduler.registerBeforeFrameCallback(drawchart);
|
|
scheduler.draw();
|
|
|
|
return () => {
|
|
scheduler.unregisterBeforeFrameCallback(drawchart);
|
|
};
|
|
}, [drawchart, scheduler]);
|
|
|
|
const onMouseDrag = useCallback(
|
|
(evt: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!chartCanvas || !chartView || !startInteractionVector) {
|
|
return;
|
|
}
|
|
|
|
const configDelta = getConfigViewTranslationBetweenVectors(
|
|
evt.nativeEvent.offsetX,
|
|
evt.nativeEvent.offsetY,
|
|
startInteractionVector,
|
|
chartView,
|
|
chartCanvas
|
|
);
|
|
|
|
if (!configDelta) {
|
|
return;
|
|
}
|
|
|
|
canvasPoolManager.dispatch('transform config view', [configDelta, chartView]);
|
|
setStartInteractionVector(
|
|
getPhysicalSpacePositionFromOffset(
|
|
evt.nativeEvent.offsetX,
|
|
evt.nativeEvent.offsetY
|
|
)
|
|
);
|
|
},
|
|
[chartCanvas, chartView, startInteractionVector, canvasPoolManager]
|
|
);
|
|
|
|
const onCanvasMouseMove = useCallback(
|
|
(evt: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!chartCanvas || !chartView) {
|
|
return;
|
|
}
|
|
|
|
const configSpaceMouse = chartView.getTransformedConfigViewCursor(
|
|
vec2.fromValues(evt.nativeEvent.offsetX, evt.nativeEvent.offsetY),
|
|
chartCanvas
|
|
);
|
|
|
|
setConfigSpaceCursor(configSpaceMouse);
|
|
|
|
if (startInteractionVector) {
|
|
onMouseDrag(evt);
|
|
setLastInteraction('pan');
|
|
} else {
|
|
setLastInteraction(null);
|
|
}
|
|
},
|
|
[chartCanvas, chartView, onMouseDrag, startInteractionVector]
|
|
);
|
|
|
|
const onMapCanvasMouseUp = useCallback(() => {
|
|
setConfigSpaceCursor(null);
|
|
setLastInteraction(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('mouseup', onMapCanvasMouseUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mouseup', onMapCanvasMouseUp);
|
|
};
|
|
}, [onMapCanvasMouseUp]);
|
|
|
|
const onWheelCenterZoom = useWheelCenterZoom(chartCanvas, chartView, canvasPoolManager);
|
|
const onCanvasScroll = useCanvasScroll(chartCanvas, chartView, canvasPoolManager);
|
|
|
|
useCanvasZoomOrScroll({
|
|
setConfigSpaceCursor,
|
|
setLastInteraction,
|
|
handleWheel: onWheelCenterZoom,
|
|
handleScroll: onCanvasScroll,
|
|
canvas: chartCanvasRef,
|
|
});
|
|
|
|
useInteractionViewCheckPoint({
|
|
view: chartView,
|
|
lastInteraction,
|
|
});
|
|
|
|
// When a user click anywhere outside the spans, clear cursor and selected node
|
|
useEffect(() => {
|
|
const onClickOutside = (evt: MouseEvent) => {
|
|
if (!chartCanvasRef || chartCanvasRef.contains(evt.target as Node)) {
|
|
return;
|
|
}
|
|
setConfigSpaceCursor(null);
|
|
};
|
|
|
|
document.addEventListener('click', onClickOutside);
|
|
|
|
return () => {
|
|
document.removeEventListener('click', onClickOutside);
|
|
};
|
|
});
|
|
|
|
const onCanvasMouseLeave = useCallback(() => {
|
|
setConfigSpaceCursor(null);
|
|
setStartInteractionVector(null);
|
|
setLastInteraction(null);
|
|
}, []);
|
|
|
|
const onCanvasMouseDown = useCallback((evt: React.MouseEvent<HTMLCanvasElement>) => {
|
|
setLastInteraction('click');
|
|
setStartInteractionVector(
|
|
getPhysicalSpacePositionFromOffset(evt.nativeEvent.offsetX, evt.nativeEvent.offsetY)
|
|
);
|
|
}, []);
|
|
|
|
const onCanvasMouseUp = useCallback(
|
|
(evt: React.MouseEvent<HTMLCanvasElement>) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
|
|
if (!chartView) {
|
|
return;
|
|
}
|
|
|
|
if (!configSpaceCursor) {
|
|
setLastInteraction(null);
|
|
setStartInteractionVector(null);
|
|
return;
|
|
}
|
|
|
|
setLastInteraction(null);
|
|
setStartInteractionVector(null);
|
|
},
|
|
[configSpaceCursor, chartView]
|
|
);
|
|
|
|
let message = t('Profile has no measurements');
|
|
const canRenderChart = chart?.series.every(
|
|
s => s.points.length >= FlamegraphChartModel.MIN_RENDERABLE_POINTS
|
|
);
|
|
|
|
if (chart && !canRenderChart) {
|
|
const profileIsTooShortToDisplayMeasurements = chart.configSpace.width < 200 * 1e6;
|
|
const didNotCollectAnyMeasurements = chart.series.every(s => s.points.length === 0);
|
|
|
|
if (profileIsTooShortToDisplayMeasurements) {
|
|
message = t(
|
|
'Profile is too short to display measurements, minimum duration is at least 200ms'
|
|
);
|
|
}
|
|
|
|
if (didNotCollectAnyMeasurements) {
|
|
message = noMeasurementMessage || t('Profile has no measurements');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<Canvas
|
|
ref={setChartCanvasRef}
|
|
onMouseMove={onCanvasMouseMove}
|
|
onMouseLeave={onCanvasMouseLeave}
|
|
onMouseUp={onCanvasMouseUp}
|
|
onMouseDown={onCanvasMouseDown}
|
|
cursor={lastInteraction === 'pan' ? 'grabbing' : 'default'}
|
|
/>
|
|
{configSpaceCursor && chartRenderer && chartCanvas && chartView && chart ? (
|
|
<FlamegraphChartTooltip
|
|
chart={chart}
|
|
configSpaceCursor={configSpaceCursor}
|
|
chartCanvas={chartCanvas}
|
|
chartView={chartView}
|
|
chartRenderer={chartRenderer}
|
|
canvasBounds={canvasBounds}
|
|
/>
|
|
) : null}
|
|
{/* transaction loads after profile, so we want to show loading even if it's in initial state */}
|
|
{profiles.type === 'loading' || profiles.type === 'initial' ? (
|
|
<CollapsibleTimelineLoadingIndicator />
|
|
) : profiles.type === 'resolved' && !canRenderChart ? (
|
|
<CollapsibleTimelineMessage>{message}</CollapsibleTimelineMessage>
|
|
) : null}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
const Canvas = styled('canvas')<{cursor?: CSSProperties['cursor']}>`
|
|
width: 100%;
|
|
height: 100%;
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
user-select: none;
|
|
cursor: ${p => p.cursor};
|
|
`;
|