diff --git a/.changeset/red-frogs-drive.md b/.changeset/red-frogs-drive.md new file mode 100644 index 00000000..915ee1c1 --- /dev/null +++ b/.changeset/red-frogs-drive.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Add ability to resize trace waterfall subpanel diff --git a/packages/app/src/TimelineChart.tsx b/packages/app/src/TimelineChart.tsx index 96f831ef..9884f56f 100644 --- a/packages/app/src/TimelineChart.tsx +++ b/packages/app/src/TimelineChart.tsx @@ -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', ); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index ecef6336..2695398c 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -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', diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index e6b67583..cac03700 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -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 ( - +
- Service Map diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 4e10f096..0633a180 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -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 ? ( -
Loading Traces...
- ) : rows == null ? ( -
- An unknown error occurred. -
- ) : ( - {}} - rowHeight={22} - labelWidth={300} - onClick={ts => { - // onTimeClick(ts + startedAt); - }} - onEventClick={event => { - onClick?.({ id: event.id, type: event.type ?? '' }); - }} - cursors={[]} - rows={timelineRows} - initialScrollRowIndex={initialScrollRowIndex} - /> - )} +
+ {isFetching ? ( +
Loading Traces...
+ ) : rows == null ? ( +
+ An unknown error occurred. +
+ ) : ( + {}} + rowHeight={22} + labelWidth={300} + onClick={ts => { + // onTimeClick(ts + startedAt); + }} + onEventClick={event => { + onClick?.({ id: event.id, type: event.type ?? '' }); + }} + cursors={[]} + rows={timelineRows} + initialScrollRowIndex={initialScrollRowIndex} + /> + )} +
+ ); } diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index 26aaf6aa..25b58235 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -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( { 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 {}; diff --git a/packages/app/src/hooks/useResizable.tsx b/packages/app/src/hooks/useResizable.tsx index c27b4f94..5e406dd4 100644 --- a/packages/app/src/hooks/useResizable.tsx +++ b/packages/app/src/hooks/useResizable.tsx @@ -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) => { 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; diff --git a/packages/app/styles/ResizablePanel.module.scss b/packages/app/styles/ResizablePanel.module.scss index c1e92d55..f0934951 100644 --- a/packages/app/styles/ResizablePanel.module.scss +++ b/packages/app/styles/ResizablePanel.module.scss @@ -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; + } +}