mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
Resizable Waterfall Traces Panel (#1328)
As requested by some users, we should have a way to resize the traces waterfall view (if there is enough trace data) so that they can see more of the trace, and still be able to click the trace events to view details https://github.com/user-attachments/assets/d1b77887-4f6e-4972-b4b4-fefd70dd344e Fixes HDX-2677
This commit is contained in:
parent
59c6655a01
commit
2743d85b37
10 changed files with 221 additions and 63 deletions
5
.changeset/red-frogs-drive.md
Normal file
5
.changeset/red-frogs-drive.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
Add ability to resize trace waterfall subpanel
|
||||
|
|
@ -377,7 +377,7 @@ export default function TimelineChart({
|
|||
const prevScale = usePrevious(scale);
|
||||
|
||||
const initialWidthPercent = (initialLabelWidth / window.innerWidth) * 100;
|
||||
const { width: labelWidthPercent, startResize } = useResizable(
|
||||
const { size: labelWidthPercent, startResize } = useResizable(
|
||||
initialWidthPercent,
|
||||
'left',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -472,7 +472,7 @@ export default function DBRowSidePanelErrorBoundary({
|
|||
const drawerZIndex = contextZIndex + 10;
|
||||
|
||||
const initialWidth = 80;
|
||||
const { width, startResize } = useResizable(initialWidth);
|
||||
const { size, startResize } = useResizable(initialWidth);
|
||||
|
||||
// Keep track of sub-drawers so we can disable closing this root drawer
|
||||
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
|
||||
|
|
@ -512,7 +512,7 @@ export default function DBRowSidePanelErrorBoundary({
|
|||
}
|
||||
}}
|
||||
position="right"
|
||||
size={`${width}vw`}
|
||||
size={`${size}vw`}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '0',
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
isFieldPinned,
|
||||
pinnedFilters,
|
||||
} = usePinnedFilters(sourceId ?? null);
|
||||
const { width, startResize } = useResizable(16, 'left');
|
||||
const { size, startResize } = useResizable(16, 'left');
|
||||
|
||||
const { data: jsonColumns } = useJsonColumns({
|
||||
databaseName: chartConfig.from.databaseName,
|
||||
|
|
@ -908,7 +908,7 @@ const DBSearchPageFiltersComponent = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<Box className={classes.filtersPanel} style={{ width: `${width}%` }}>
|
||||
<Box className={classes.filtersPanel} style={{ width: `${size}%` }}>
|
||||
<div className={resizeStyles.resizeHandle} onMouseDown={startResize} />
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
|
|
|
|||
|
|
@ -209,7 +209,6 @@ export default function DBTracePanel({
|
|||
)}
|
||||
{traceSourceData != null && eventRowWhere != null && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Group>
|
||||
<Text size="sm" c="dark.2" my="sm">
|
||||
Service Map
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import {
|
|||
SourceKind,
|
||||
TSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Text } from '@mantine/core';
|
||||
import { Divider, Text } from '@mantine/core';
|
||||
|
||||
import { ContactSupportText } from '@/components/ContactSupportText';
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
import useResizable from '@/hooks/useResizable';
|
||||
import useRowWhere from '@/hooks/useRowWhere';
|
||||
import {
|
||||
getDisplayedTimestampValueExpression,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
import TimelineChart from '@/TimelineChart';
|
||||
|
||||
import styles from '@/../styles/LogSidePanel.module.scss';
|
||||
import resizeStyles from '@/../styles/ResizablePanel.module.scss';
|
||||
|
||||
export type SpanRow = {
|
||||
Body: string;
|
||||
|
|
@ -263,6 +265,8 @@ export function DBTraceWaterfallChartContainer({
|
|||
body: string;
|
||||
};
|
||||
}) {
|
||||
const { size, startResize } = useResizable(30, 'bottom');
|
||||
|
||||
const { rows: traceRowsData, isFetching: traceIsFetching } =
|
||||
useEventsAroundFocus({
|
||||
tableSource: traceTableSource,
|
||||
|
|
@ -551,35 +555,51 @@ export function DBTraceWaterfallChartContainer({
|
|||
return v.id === highlightedRowWhere;
|
||||
});
|
||||
|
||||
const heightPx = (size / 100) * window.innerHeight;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<div className="my-3">Loading Traces...</div>
|
||||
) : rows == null ? (
|
||||
<div>
|
||||
An unknown error occurred. <ContactSupportText />
|
||||
</div>
|
||||
) : (
|
||||
<TimelineChart
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
maxHeight: 400,
|
||||
}}
|
||||
scale={1}
|
||||
setScale={() => {}}
|
||||
rowHeight={22}
|
||||
labelWidth={300}
|
||||
onClick={ts => {
|
||||
// onTimeClick(ts + startedAt);
|
||||
}}
|
||||
onEventClick={event => {
|
||||
onClick?.({ id: event.id, type: event.type ?? '' });
|
||||
}}
|
||||
cursors={[]}
|
||||
rows={timelineRows}
|
||||
initialScrollRowIndex={initialScrollRowIndex}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
maxHeight: `${heightPx}px`,
|
||||
}}
|
||||
>
|
||||
{isFetching ? (
|
||||
<div className="my-3">Loading Traces...</div>
|
||||
) : rows == null ? (
|
||||
<div>
|
||||
An unknown error occurred. <ContactSupportText />
|
||||
</div>
|
||||
) : (
|
||||
<TimelineChart
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
maxHeight: `${heightPx}px`,
|
||||
}}
|
||||
scale={1}
|
||||
setScale={() => {}}
|
||||
rowHeight={22}
|
||||
labelWidth={300}
|
||||
onClick={ts => {
|
||||
// onTimeClick(ts + startedAt);
|
||||
}}
|
||||
onEventClick={event => {
|
||||
onClick?.({ id: event.id, type: event.type ?? '' });
|
||||
}}
|
||||
cursors={[]}
|
||||
rows={timelineRows}
|
||||
initialScrollRowIndex={initialScrollRowIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Divider
|
||||
mt="md"
|
||||
className={resizeStyles.resizeYHandle}
|
||||
onMouseDown={startResize}
|
||||
style={{ position: 'relative', bottom: 0 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
|
|
@ -111,7 +111,7 @@ describe('DBTraceWaterfallChartContainer', () => {
|
|||
const renderComponent = (
|
||||
logTableSource: typeof mockLogTableSource | null = mockLogTableSource,
|
||||
) => {
|
||||
return render(
|
||||
return renderWithMantine(
|
||||
<DBTraceWaterfallChartContainer
|
||||
traceTableSource={mockTraceTableSource}
|
||||
logTableSource={logTableSource}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ describe('useResizable', () => {
|
|||
|
||||
it('should initialize with the provided width', () => {
|
||||
const { result } = renderHook(() => useResizable(20));
|
||||
expect(result.current.width).toBe(20);
|
||||
expect(result.current.size).toBe(20);
|
||||
});
|
||||
|
||||
it('should handle right resize correctly', () => {
|
||||
|
|
@ -57,7 +57,7 @@ describe('useResizable', () => {
|
|||
|
||||
// Moving right should decrease width for right panel
|
||||
// Delta: 100px = 10% of window width
|
||||
expect(result.current.width).toBe(10); // 20 - 10
|
||||
expect(result.current.size).toBe(10); // 20 - 10
|
||||
});
|
||||
|
||||
it('should handle left resize correctly', () => {
|
||||
|
|
@ -75,7 +75,7 @@ describe('useResizable', () => {
|
|||
|
||||
// Moving right should increase width for left panel
|
||||
// Delta: 100px = 10% of window width
|
||||
expect(result.current.width).toBe(30); // 20 + 10
|
||||
expect(result.current.size).toBe(30); // 20 + 10
|
||||
});
|
||||
|
||||
it('should respect minimum width constraint', () => {
|
||||
|
|
@ -90,7 +90,7 @@ describe('useResizable', () => {
|
|||
fireEvent(document, moveEvent);
|
||||
});
|
||||
|
||||
expect(result.current.width).toBe(10); // Should not go below MIN_PANEL_WIDTH_PERCENT
|
||||
expect(result.current.size).toBe(10); // Should not go below MIN_PANEL_WIDTH_PERCENT
|
||||
});
|
||||
|
||||
it('should respect maximum width constraint', () => {
|
||||
|
|
@ -106,7 +106,7 @@ describe('useResizable', () => {
|
|||
});
|
||||
|
||||
// Max width should be (1000 - 25) / 1000 * 100 = 97.5%
|
||||
expect(result.current.width).toBeLessThanOrEqual(97.5);
|
||||
expect(result.current.size).toBeLessThanOrEqual(97.5);
|
||||
});
|
||||
|
||||
it('should cleanup event listeners on unmount', () => {
|
||||
|
|
@ -130,6 +130,103 @@ describe('useResizable', () => {
|
|||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
describe('vertical resizing', () => {
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalOffsetHeight = document.body.offsetHeight;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
|
||||
Object.defineProperty(document.body, 'offsetHeight', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalInnerHeight,
|
||||
});
|
||||
Object.defineProperty(document.body, 'offsetHeight', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalOffsetHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle bottom resize correctly', () => {
|
||||
const { result } = renderHook(() => useResizable(20, 'bottom'));
|
||||
|
||||
act(() => {
|
||||
const startEvent = new MouseEvent('mousedown', { clientY: 500 });
|
||||
result.current.startResize(startEvent as any);
|
||||
|
||||
// Move mouse down by 100px
|
||||
const moveEvent = new MouseEvent('mousemove', { clientY: 600 });
|
||||
fireEvent(document, moveEvent);
|
||||
});
|
||||
|
||||
// Moving down should decrease height for bottom panel
|
||||
// Delta: 100px = 10% of window height
|
||||
expect(result.current.size).toBe(30); // 20 + 10
|
||||
});
|
||||
|
||||
it('should handle top resize correctly', () => {
|
||||
const { result } = renderHook(() => useResizable(20, 'top'));
|
||||
|
||||
act(() => {
|
||||
const startEvent = new MouseEvent('mousedown', { clientY: 500 });
|
||||
result.current.startResize(startEvent as any);
|
||||
|
||||
// Move mouse down by 100px
|
||||
const moveEvent = new MouseEvent('mousemove', { clientY: 600 });
|
||||
fireEvent(document, moveEvent);
|
||||
});
|
||||
|
||||
// Moving down should increase height for top panel
|
||||
// Delta: 100px = 10% of window height
|
||||
expect(result.current.size).toBe(10); // 20 - 10
|
||||
});
|
||||
|
||||
it('should respect minimum height constraint (bottom)', () => {
|
||||
const { result } = renderHook(() => useResizable(20, 'bottom'));
|
||||
|
||||
act(() => {
|
||||
const startEvent = new MouseEvent('mousedown', { clientY: 500 });
|
||||
result.current.startResize(startEvent as any);
|
||||
|
||||
// Try to resize smaller than minimum (10%)
|
||||
const moveEvent = new MouseEvent('mousemove', { clientY: 800 });
|
||||
fireEvent(document, moveEvent);
|
||||
});
|
||||
|
||||
expect(result.current.size).toBe(50); // Should not go below MIN_PANEL_WIDTH_PERCENT
|
||||
});
|
||||
|
||||
it('should respect maximum height constraint (top)', () => {
|
||||
const { result } = renderHook(() => useResizable(20, 'top'));
|
||||
|
||||
act(() => {
|
||||
const startEvent = new MouseEvent('mousedown', { clientY: 500 });
|
||||
result.current.startResize(startEvent as any);
|
||||
|
||||
// Try to resize larger than maximum
|
||||
const moveEvent = new MouseEvent('mousemove', { clientY: 1000 });
|
||||
fireEvent(document, moveEvent);
|
||||
});
|
||||
|
||||
// Max height should be (1000 - 25) / 1000 * 100 = 97.5%
|
||||
expect(result.current.size).toBeLessThanOrEqual(97.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -6,36 +6,48 @@ import {
|
|||
useState,
|
||||
} from 'react';
|
||||
|
||||
const MIN_PANEL_WIDTH_PERCENT = 10;
|
||||
const MAX_PANEL_OFFSET = 25;
|
||||
const MIN_PANEL_PERCENT = 10;
|
||||
const MAX_PANEL_OFFSET_PX = 25;
|
||||
|
||||
type ResizeDirection = 'left' | 'right';
|
||||
type ResizeDirection = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
function useResizable(
|
||||
initialWidthPercent: number,
|
||||
initialSizePercent: number,
|
||||
direction: ResizeDirection = 'right',
|
||||
) {
|
||||
const [widthPercent, setWidthPercent] = useState(initialWidthPercent);
|
||||
const [sizePercentage, setSizePercentage] = useState(initialSizePercent);
|
||||
|
||||
// Track drag start
|
||||
const startPosRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
const startSizeRef = useRef(0);
|
||||
|
||||
const isVertical = direction === 'top' || direction === 'bottom';
|
||||
const axis: 'clientX' | 'clientY' = isVertical ? 'clientY' : 'clientX';
|
||||
|
||||
// For right/bottom, positive drag increases size; for left/top, it decreases.
|
||||
const directionMultiplier =
|
||||
direction === 'right' || direction === 'top' ? -1 : 1;
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: globalThis.MouseEvent) => {
|
||||
const delta = e.clientX - startPosRef.current;
|
||||
const deltaPercent = (delta / window.innerWidth) * 100;
|
||||
const directionMultiplier = direction === 'right' ? -1 : 1;
|
||||
const containerSize = isVertical ? window.innerHeight : window.innerWidth;
|
||||
const delta = e[axis] - startPosRef.current;
|
||||
const deltaPercent = (delta / containerSize) * 100;
|
||||
|
||||
const newWidth =
|
||||
startWidthRef.current + deltaPercent * directionMultiplier;
|
||||
const maxWidth =
|
||||
((document.body.offsetWidth - MAX_PANEL_OFFSET) / window.innerWidth) *
|
||||
100;
|
||||
const offsetWidth = isVertical
|
||||
? window.innerHeight
|
||||
: document.body.offsetWidth;
|
||||
// Clamp to min and max
|
||||
const maxPercent =
|
||||
((offsetWidth - MAX_PANEL_OFFSET_PX) / containerSize) * 100;
|
||||
|
||||
setWidthPercent(
|
||||
Math.min(Math.max(MIN_PANEL_WIDTH_PERCENT, newWidth), maxWidth),
|
||||
);
|
||||
const minPercent = MIN_PANEL_PERCENT;
|
||||
|
||||
const newSize = startSizeRef.current + deltaPercent * directionMultiplier;
|
||||
|
||||
setSizePercentage(Math.min(Math.max(minPercent, newSize), maxPercent));
|
||||
},
|
||||
[direction],
|
||||
[isVertical, axis, directionMultiplier],
|
||||
);
|
||||
|
||||
const endResize = useCallback(() => {
|
||||
|
|
@ -46,12 +58,12 @@ function useResizable(
|
|||
const startResize = useCallback(
|
||||
(e: ReactMouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
startPosRef.current = e.clientX;
|
||||
startWidthRef.current = widthPercent;
|
||||
startPosRef.current = e[axis];
|
||||
startSizeRef.current = sizePercentage;
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', endResize);
|
||||
},
|
||||
[widthPercent, handleResize, endResize],
|
||||
[axis, sizePercentage, handleResize, endResize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -61,7 +73,10 @@ function useResizable(
|
|||
};
|
||||
}, [handleResize, endResize]);
|
||||
|
||||
return { width: widthPercent, startResize };
|
||||
return {
|
||||
size: sizePercentage,
|
||||
startResize,
|
||||
};
|
||||
}
|
||||
|
||||
export default useResizable;
|
||||
|
|
|
|||
|
|
@ -19,3 +19,25 @@
|
|||
margin-right: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.resizeYHandle {
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
transition: all 150ms ease;
|
||||
z-index: 10;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--mantine-color-dark-4);
|
||||
height: 8px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue