From 9cad5b30c7f6cbd80d0132a0c1fceb4ba9893796 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Tue, 2 Jun 2026 18:22:45 +0200 Subject: [PATCH 1/2] feat: trace panel inline split detail --- .changeset/tough-ads-pay.md | 5 + packages/app/src/LogSidePanelElements.tsx | 83 +++--- packages/app/src/SessionSidePanel.tsx | 6 +- .../app/src/components/DBRowDataPanel.tsx | 28 ++ .../src/components/DBRowJsonViewer.test.tsx | 21 +- .../app/src/components/DBRowJsonViewer.tsx | 37 +-- .../app/src/components/DBRowSidePanel.tsx | 34 +-- .../src/components/DBRowSidePanelHeader.tsx | 34 ++- packages/app/src/components/DBTracePanel.tsx | 261 +++++++++++++----- .../src/components/DBTraceWaterfallChart.tsx | 82 ++++-- packages/app/src/components/DrawerUtils.tsx | 16 +- .../TimelineChart/TimelineChart.tsx | 1 - .../TimelineChart/TimelineXAxis.tsx | 21 +- .../TimelineChart/__tests__/utils.test.ts | 34 ++- .../app/src/components/TimelineChart/utils.ts | 33 ++- .../__tests__/DBTracePanel.test.tsx | 12 + .../app/styles/ResizablePanel.module.scss | 22 ++ 17 files changed, 494 insertions(+), 236 deletions(-) create mode 100644 .changeset/tough-ads-pay.md diff --git a/.changeset/tough-ads-pay.md b/.changeset/tough-ads-pay.md new file mode 100644 index 0000000000..ac693cec18 --- /dev/null +++ b/.changeset/tough-ads-pay.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: trace panel inline split detail diff --git a/packages/app/src/LogSidePanelElements.tsx b/packages/app/src/LogSidePanelElements.tsx index 00586e1b75..4a9e08a51d 100644 --- a/packages/app/src/LogSidePanelElements.tsx +++ b/packages/app/src/LogSidePanelElements.tsx @@ -3,7 +3,16 @@ import * as React from 'react'; import Link from 'next/link'; import cx from 'classnames'; import { JSONTree } from 'react-json-tree'; -import { Alert, Button, CloseButton, Kbd, Text, Tooltip } from '@mantine/core'; +import { + Alert, + Button, + Group, + Kbd, + Modal, + Stack, + Text, + Tooltip, +} from '@mantine/core'; import { IconChevronDown, IconChevronRight, @@ -485,37 +494,49 @@ export const NetworkBody = ({ /** * Keyboard shortcuts */ -export const LogSidePanelKbdShortcuts = () => { - const [isDismissed, setDismissed] = useLocalStorage( - 'kbd-shortcuts-dismissed', - false, - ); - - const handleDismiss = React.useCallback(() => { - setDismissed(true); - }, [setDismissed]); - - if (isDismissed) { - return null; - } - +const SHORTCUTS = [ + { keys: ['esc'], label: 'Close panel' }, + { keys: ['←', '→'], label: 'Navigate between events' }, + { keys: ['↑', '↓'], label: 'Navigate between events' }, + { keys: ['k', 'j'], label: 'Navigate between events' }, + { keys: ['⌘/Ctrl', 'scroll'], label: 'Zoom trace timeline' }, +] as const; + +export const KeyboardShortcutsModal = ({ + opened, + onClose, +}: { + opened: boolean; + onClose: () => void; +}) => { return ( -
-
-
-
- Use ← - → arrow keys or k - j to move through events -
-
-
- ESC to close -
-
- -
-
+ + + {SHORTCUTS.map(({ keys, label }) => ( + + + {keys.map((key, i) => ( + + {i > 0 && ( + + + + + )} + {key} + + ))} + + {label} + + ))} + + ); }; diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index ddc26e3cca..2cee84a423 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -13,7 +13,7 @@ import { IconLink, IconX } from '@tabler/icons-react'; import { DrawerFullWidthToggle, - getInitialDrawerWidthPercent, + INITIAL_DRAWER_WIDTH_PERCENT, } from '@/components/DrawerUtils'; import useResizable from '@/hooks/useResizable'; import { @@ -54,11 +54,11 @@ export default function SessionSidePanel({ const [subDrawerOpen, setSubDrawerOpen] = useState(false); const { size, setSize, startResize } = useResizable( - getInitialDrawerWidthPercent(), + INITIAL_DRAWER_WIDTH_PERCENT, ); const isFullWidth = size >= 99; const toggleFullWidth = useCallback(() => { - setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100); + setSize(isFullWidth ? INITIAL_DRAWER_WIDTH_PERCENT : 100); }, [isFullWidth, setSize]); useHotkeys( diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 2a38f2d105..ef2bd8ed1c 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -190,6 +190,34 @@ export function useRowData({ }; } +// Detects whether a normalized row carries Kubernetes resource attributes, used +// to conditionally surface the Infrastructure tab/panel. Requires the source to +// expose resource attributes; returns false (rather than throwing) on any gap. +export function rowHasK8sContext( + source: TSource | null | undefined, + normalizedRow: Record | null | undefined, +): boolean { + try { + if ( + source == null || + !('resourceAttributesExpression' in source) || + !source.resourceAttributesExpression || + !normalizedRow + ) { + return false; + } + + const resourceAttrs = normalizedRow[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]; + return ( + resourceAttrs?.['k8s.pod.uid'] != null || + resourceAttrs?.['k8s.node.name'] != null + ); + } catch (e) { + console.error(e); + return false; + } +} + export function getJSONColumnNames(meta: ResponseJSON['meta'] | undefined) { return ( meta diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx index e889a6df69..d6d1c81dba 100644 --- a/packages/app/src/components/DBRowJsonViewer.test.tsx +++ b/packages/app/src/components/DBRowJsonViewer.test.tsx @@ -61,11 +61,28 @@ describe('DBRowJsonViewer', () => { ); }; + // Line action buttons are now icon-only; locate them by their `title` + // tooltip. Maps the friendly action name to a unique title substring. + const ACTION_TITLE: Record = { + Search: 'search for this value only', + 'Add to Filters': 'add to filters', + Column: 'column to results table', + 'Copy Object': 'copy object', + 'Copy Value': 'copy value', + }; + + const findActionButton = (line: HTMLElement, buttonText: string) => { + const needle = (ACTION_TITLE[buttonText] ?? buttonText).toLowerCase(); + return within(line).getByTitle((content: string) => + (content ?? '').toLowerCase().includes(needle), + ); + }; + // Helper to click a button on a line const clickLineButton = (fieldText: string, buttonText: string) => { const line = screen.getByText(fieldText).closest('.line')! as HTMLElement; fireEvent.mouseEnter(line); - const button = within(line).getByText(buttonText); + const button = findActionButton(line, buttonText); fireEvent.click(button); }; @@ -84,7 +101,7 @@ describe('DBRowJsonViewer', () => { .getByText(childField) .closest('.line')! as HTMLElement; fireEvent.mouseEnter(childLine); - const button = within(childLine).getByText(buttonText); + const button = findActionButton(childLine, buttonText); fireEvent.click(button); }; diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index e6aea39c5f..3a7733a599 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -383,12 +383,7 @@ export function DBRowJsonViewer({ ) { actions.push({ key: 'add-to-search', - label: ( - - - Add to Filters - - ), + label: , title: 'Add to Filters', onClick: () => { let filterFieldPath = fieldPath; @@ -417,12 +412,7 @@ export function DBRowJsonViewer({ if (generateSearchUrl && typeof value !== 'object') { actions.push({ key: 'search', - label: ( - - - Search - - ), + label: , title: 'Search for this value only', onClick: () => { let searchFieldPath = fieldPath; @@ -521,17 +511,7 @@ export function DBRowJsonViewer({ const isIncluded = displayedColumns?.includes(columnFieldPath); actions.push({ key: 'toggle-column', - label: isIncluded ? ( - - - Column - - ) : ( - - - Column - - ), + label: isIncluded ? : , title: isIncluded ? `Remove ${fieldPath} column from results table` : `Add ${fieldPath} column to results table`, @@ -578,18 +558,15 @@ export function DBRowJsonViewer({ if (typeof value === 'object') { actions.push({ key: 'copy-object', - label: 'Copy Object', + label: , + title: 'Copy object', onClick: handleCopyObject, }); } else { actions.push({ key: 'copy-value', - label: ( - - - Copy Value - - ), + label: , + title: 'Copy value', onClick: async () => { const copied = await copyTextToClipboard( typeof value === 'string' diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 1bd6dcde7d..1b1929dc28 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -31,7 +31,6 @@ import DBRowSidePanelHeader, { import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; -import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements'; import { getEventBody } from '@/source'; import TabBar from '@/TabBar'; import { SearchConfig } from '@/types'; @@ -41,11 +40,11 @@ import { useZIndex, ZIndexContext } from '@/zIndex'; import ServiceMapSidePanel from './ServiceMap/ServiceMapSidePanel'; import ContextSubpanel from './ContextSidePanel'; import DBInfraPanel from './DBInfraPanel'; -import { RowDataPanel, useRowData } from './DBRowDataPanel'; +import { RowDataPanel, rowHasK8sContext, useRowData } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; import { DBSessionPanel, useSessionId } from './DBSessionPanel'; import DBTracePanel from './DBTracePanel'; -import { getInitialDrawerWidthPercent } from './DrawerUtils'; +import { INITIAL_DRAWER_WIDTH_PERCENT } from './DrawerUtils'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -289,26 +288,10 @@ const DBRowSidePanel = ({ enabled: rowId != null, }); - const hasK8sContext = useMemo(() => { - try { - if ( - !('resourceAttributesExpression' in source) || - !source?.resourceAttributesExpression || - !normalizedRow - ) { - return false; - } - - const resourceAttrs = normalizedRow['__hdx_resource_attributes']; - return ( - resourceAttrs?.['k8s.pod.uid'] != null || - resourceAttrs?.['k8s.node.name'] != null - ); - } catch (e) { - console.error(e); - return false; - } - }, [source, normalizedRow]); + const hasK8sContext = useMemo( + () => rowHasK8sContext(source, normalizedRow), + [source, normalizedRow], + ); const initialRowHighlightHint = useMemo(() => { if (normalizedRow) { @@ -554,7 +537,6 @@ const DBRowSidePanel = ({ )} - ); }; @@ -572,12 +554,12 @@ export default function DBRowSidePanelErrorBoundary({ const drawerZIndex = contextZIndex + 10; const { size, setSize, startResize } = useResizable( - getInitialDrawerWidthPercent(), + INITIAL_DRAWER_WIDTH_PERCENT, ); const isFullWidth = size >= 99; const toggleFullWidth = useCallback(() => { - setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100); + setSize(isFullWidth ? INITIAL_DRAWER_WIDTH_PERCENT : 100); }, [isFullWidth, setSize]); // Keep track of sub-drawers so we can disable closing this root drawer diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx index b017905909..b37a1f383a 100644 --- a/packages/app/src/components/DBRowSidePanelHeader.tsx +++ b/packages/app/src/components/DBRowSidePanelHeader.tsx @@ -6,10 +6,12 @@ import React, { useState, } from 'react'; import { + ActionIcon, Box, Breadcrumbs, Button, Flex, + Group, Paper, Text, Tooltip, @@ -18,8 +20,10 @@ import { import { IconArrowsDiagonal, IconArrowsDiagonalMinimize2, + IconKeyboard, } from '@tabler/icons-react'; +import { KeyboardShortcutsModal } from '@/LogSidePanelElements'; import { FormatTime } from '@/useFormatTime'; import { useUserPreferences } from '@/useUserPreferences'; import { formatDistanceToNowStrictShort } from '@/utils'; @@ -157,6 +161,7 @@ export default function DBRowSidePanelHeader({ onToggleFullWidth?: () => void; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); const { generateSearchUrl } = useContext(RowSidePanelContext); const isContentTruncated = mainContent.length > MAX_MAIN_CONTENT_LENGTH; @@ -239,13 +244,30 @@ export default function DBRowSidePanelHeader({ )} - {onToggleFullWidth && ( - - )} + + + setShortcutsOpen(true)} + aria-label="Keyboard shortcuts" + > + + + + {onToggleFullWidth && ( + + )} + + setShortcutsOpen(false)} + /> {!bodyConfigured ? null : mainContent ? ( (); +}; + +const eventRowWhereParser = parseAsJsonEncoded(); -enum Tab { +enum SpanDetailTab { Overview = 'overview', Parsed = 'parsed', + Infrastructure = 'infrastructure', +} + +// Renders the inline detail for the currently-selected span. Mounted only while +// a span is selected, so it can call useRowData with a real source rather than +// the parent passing a placeholder when nothing is selected. Owns the active +// tab, which resets to Overview when the panel is closed and reopened. +function SpanDetailPanel({ + source, + rowId, + aliasWith, + onClose, +}: { + source: TSource; + rowId: string; + aliasWith?: WithClause[]; + onClose: () => void; +}) { + const [displayedTab, setDisplayedTab] = useState( + SpanDetailTab.Overview, + ); + + const { data: rowData } = useRowData({ source, rowId, aliasWith }); + const normalizedRow = rowData?.data?.[0]; + + const hasK8sContext = useMemo( + () => rowHasK8sContext(source, normalizedRow), + [source, normalizedRow], + ); + + // If the selected span loses k8s context (e.g. switching spans) while the + // Infrastructure tab is active, fall back to Overview so we don't show a + // blank panel. Derived rather than synced via an effect. + const effectiveTab = + displayedTab === SpanDetailTab.Infrastructure && !hasK8sContext + ? SpanDetailTab.Overview + : displayedTab; + + return ( + <> +
+ setDisplayedTab(v)} + /> + + + + + +
+ {effectiveTab === SpanDetailTab.Overview && ( + + )} + {effectiveTab === SpanDetailTab.Parsed && ( + + )} + {effectiveTab === SpanDetailTab.Infrastructure && hasK8sContext && ( + + + + )} + + ); } export default function DBTracePanel({ @@ -146,9 +244,33 @@ export default function DBTracePanel({ const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = useState(false); - const [displayedTab, setDisplayedTab] = useState(Tab.Overview); + // Parent owns the horizontal split sizing; the waterfall lives on the left + // and the selected span's detail renders inline on the right. + const { size: rightPanelSize, startResize: startHorizontalResize } = + useResizable(35, 'right'); + + const handleCloseSpanDetails = useCallback(() => { + setEventRowWhere(null); + }, [setEventRowWhere]); + + const selectedSpanSource = useMemo(() => { + if (!eventRowWhere) return null; + if (eventRowWhere.type === SourceKind.Log && logSourceData) { + return logSourceData; + } + return traceSourceData; + }, [eventRowWhere, logSourceData, traceSourceData]); + return ( -
+
@@ -238,70 +360,67 @@ export default function DBTracePanel({ )} - {traceSourceData?.kind === SourceKind.Trace && traceId && ( - - )} - {traceSourceData != null && eventRowWhere != null && ( - <> - - Event Details - - setDisplayedTab(v)} - /> - {displayedTab === Tab.Overview && ( - +
+ {traceSourceData?.kind === SourceKind.Trace && traceId && ( + )} - {displayedTab === Tab.Parsed && ( - +
+ + {eventRowWhere != null && ( + + )} + + {traceSourceData != null && + eventRowWhere != null && + selectedSpanSource != null && ( +
+ +
)} - - )} - {traceSourceData != null && !eventRowWhere && traceId && ( - -
- Please select a span above to view details. -
-
- )} +
); } diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index f5383204e7..be1c75a2fa 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -1,4 +1,11 @@ -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import _, { omit } from 'lodash'; import { useForm } from 'react-hook-form'; import SqlString from 'sqlstring'; @@ -19,13 +26,12 @@ import { Center, Chip, Code, - Divider, Group, Kbd, Text, Tooltip, } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +import { useDisclosure, useElementSize } from '@mantine/hooks'; import { IconChevronDown, IconChevronRight, @@ -36,7 +42,6 @@ import { ContactSupportText } from '@/components/ContactSupportText'; import SearchInputV2 from '@/components/SearchInput/SearchInputV2'; import { TimelineChart } from '@/components/TimelineChart'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; -import useResizable from '@/hooks/useResizable'; import useRowWhere, { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; import { @@ -63,7 +68,6 @@ import { import { DBHighlightedAttributesList } from './DBHighlightedAttributesList'; import styles from '@/../styles/LogSidePanel.module.scss'; -import resizeStyles from '@/../styles/ResizablePanel.module.scss'; export type SpanRow = { Body: string; @@ -451,7 +455,6 @@ export function DBTraceWaterfallChartContainer({ }; emptyState?: ReactNode; }) { - const { size, startResize } = useResizable(30, 'bottom'); const formatTime = useFormatTime(); const { @@ -569,23 +572,46 @@ export function DBTraceWaterfallChartContainer({ logRowsMeta, ]); + // Auto-select the originating span once when the panel opens — but only if + // nothing is already selected. Two cases must NOT trigger a (re-)select: + // - the user explicitly cleared the selection (closing the span detail), and + // - a selection was restored from the URL on load (deep link / reload). + // We mark the hint applied as soon as a selection exists or we apply it + // ourselves, so it never fires twice for the same hint; it re-fires only when + // the hint genuinely changes (different originating span / trace). + const appliedHighlightHintRef = useRef(null); useEffect(() => { - if (initialRowHighlightHint && onClick && highlightedRowWhere == null) { - const initialRowHighlightIndex = rows.findIndex(row => { - return ( - row.Timestamp === initialRowHighlightHint.timestamp && - row.SpanId === initialRowHighlightHint.spanId && - row.Body === initialRowHighlightHint.body - ); - }); + if (!initialRowHighlightHint || !onClick) { + return; + } - if (initialRowHighlightIndex !== -1) { - onClick?.({ - id: rows[initialRowHighlightIndex].id, - type: rows[initialRowHighlightIndex].type ?? '', - aliasWith: rows[initialRowHighlightIndex].aliasWith, - }); - } + const hintKey = `${initialRowHighlightHint.timestamp}|${initialRowHighlightHint.spanId}|${initialRowHighlightHint.body}`; + if (appliedHighlightHintRef.current === hintKey) { + return; + } + + // A selection already exists (restored from the URL, or user-chosen). Honor + // it and record the hint so we never override it — now or later. + if (highlightedRowWhere != null) { + appliedHighlightHintRef.current = hintKey; + return; + } + + const initialRowHighlightIndex = rows.findIndex(row => { + return ( + row.Timestamp === initialRowHighlightHint.timestamp && + row.SpanId === initialRowHighlightHint.spanId && + row.Body === initialRowHighlightHint.body + ); + }); + + if (initialRowHighlightIndex !== -1) { + appliedHighlightHintRef.current = hintKey; + onClick({ + id: rows[initialRowHighlightIndex].id, + type: rows[initialRowHighlightIndex].type ?? '', + aliasWith: rows[initialRowHighlightIndex].aliasWith, + }); } }, [initialRowHighlightHint, rows, onClick, highlightedRowWhere]); @@ -975,7 +1001,8 @@ export function DBTraceWaterfallChartContainer({ return v.id === highlightedRowWhere; }); - const heightPx = (size / 100) * window.innerHeight; + const { ref: timelineWrapperRef, height: timelineWrapperHeight } = + useElementSize(); return ( <> @@ -1103,10 +1130,11 @@ export function DBTraceWaterfallChartContainer({ )}
{isFetching ? ( @@ -1139,7 +1167,7 @@ export function DBTraceWaterfallChartContainer({ ) ) : ( { @@ -1154,12 +1182,6 @@ export function DBTraceWaterfallChartContainer({ /> )}
- ); } diff --git a/packages/app/src/components/DrawerUtils.tsx b/packages/app/src/components/DrawerUtils.tsx index 984dc027b1..0d898e6268 100644 --- a/packages/app/src/components/DrawerUtils.tsx +++ b/packages/app/src/components/DrawerUtils.tsx @@ -13,21 +13,7 @@ import { IconLayoutSidebarRightExpand, } from '@tabler/icons-react'; -const LARGE_SCREEN_BREAKPOINT_PX = 1440; -const LARGE_SCREEN_DRAWER_WIDTH_PERCENT = 60; -const SMALL_SCREEN_DRAWER_WIDTH_PERCENT = 80; - -export function getInitialDrawerWidthPercent(): number { - if (typeof window === 'undefined') { - return SMALL_SCREEN_DRAWER_WIDTH_PERCENT; - } - - if (window.innerWidth > LARGE_SCREEN_BREAKPOINT_PX) { - return LARGE_SCREEN_DRAWER_WIDTH_PERCENT; - } - - return SMALL_SCREEN_DRAWER_WIDTH_PERCENT; -} +export const INITIAL_DRAWER_WIDTH_PERCENT = 80; export const DrawerFullWidthToggle = React.memo<{ isFullWidth?: boolean; diff --git a/packages/app/src/components/TimelineChart/TimelineChart.tsx b/packages/app/src/components/TimelineChart/TimelineChart.tsx index f5fe68c0ec..8e35c0796a 100644 --- a/packages/app/src/components/TimelineChart/TimelineChart.tsx +++ b/packages/app/src/components/TimelineChart/TimelineChart.tsx @@ -274,7 +274,6 @@ export const TimelineChart = memo(function (props: TimelineChartProps) { maxVal={maxVal} heightRef={timelineHeightRef} labelWidth={labelWidth} - scaleRef={scaleRef} />
void; }; +// Minimum horizontal space reserved per tick label. The widest label we render +// (e.g. "1.234s") is ~45px; the extra headroom keeps adjacent labels from +// touching. Tune up for a sparser axis, down for a denser one. +const MIN_TICK_PX = 56; + export function TimelineXAxis({ maxVal, labelWidth, heightRef, - scaleRef, ref, }: { maxVal: number; labelWidth: number; heightRef: RefObject; - scaleRef: RefObject; ref: Ref; }) { const ticksContainerRef = useRef(null); @@ -45,11 +48,17 @@ export function TimelineXAxis({ return; } - const scale = scaleRef.current ?? 1; const height = heightRef.current ?? 0; const max = maxValRef.current; - const scaledMaxVal = max / scale; - const interval = calculateInterval(scaledMaxVal); + + // Budget ticks by the pixels actually available. The ticks container spans + // the (zoom-scaled) events area, so dividing its width by a minimum label + // width gives how many labels fit without overlapping. Measuring here keeps + // the axis correct on both panel resize and zoom, since both re-invoke + // recompute after the layout/scale has changed. + const ticksWidthPx = container.getBoundingClientRect().width; + const maxTicks = Math.max(1, Math.floor(ticksWidthPx / MIN_TICK_PX)); + const interval = calculateInterval(max, maxTicks); const numTicks = Math.floor(max / interval); const percSpacing = (interval / max) * 100; @@ -78,7 +87,7 @@ export function TimelineXAxis({ tick.style.marginLeft = i === 0 ? '0' : `${percSpacing.toFixed(6)}%`; label.textContent = renderMs(i * interval); } - }, [maxValRef, heightRef, scaleRef]); + }, [maxValRef, heightRef]); useImperativeHandle(ref, () => ({ recompute }), [recompute]); diff --git a/packages/app/src/components/TimelineChart/__tests__/utils.test.ts b/packages/app/src/components/TimelineChart/__tests__/utils.test.ts index b51746d61d..ddba012174 100644 --- a/packages/app/src/components/TimelineChart/__tests__/utils.test.ts +++ b/packages/app/src/components/TimelineChart/__tests__/utils.test.ts @@ -52,8 +52,9 @@ describe('calculateInterval', () => { expect(calculateInterval(10)).toBe(1); }); - it('returns 2x magnitude for value 25', () => { - expect(calculateInterval(25)).toBe(2); + it('snaps up to 5x magnitude for value 25', () => { + // value/10 = 2.5 → smallest fitting bucket is 5 (snap up), giving 5 ticks + expect(calculateInterval(25)).toBe(5); }); it('returns 5x magnitude for value 50', () => { @@ -75,4 +76,33 @@ describe('calculateInterval', () => { it('returns magnitude 100 for value 1000', () => { expect(calculateInterval(1000)).toBe(100); }); + + it('never produces more than maxTicks steps', () => { + for (const [value, maxTicks] of [ + [1000, 10], + [1000, 5], + [1000, 2], + [1000, 1], + [37, 6], + [250, 3], + ] as const) { + const interval = calculateInterval(value, maxTicks); + expect(Math.floor(value / interval)).toBeLessThanOrEqual(maxTicks); + } + }); + + it('thins ticks as the budget shrinks', () => { + // A tighter tick budget must yield a coarser (>=) interval. + expect(calculateInterval(1000, 5)).toBeGreaterThanOrEqual( + calculateInterval(1000, 10), + ); + expect(calculateInterval(1000, 2)).toBeGreaterThanOrEqual( + calculateInterval(1000, 5), + ); + }); + + it('returns a safe fallback for non-positive ranges', () => { + expect(calculateInterval(0)).toBe(1); + expect(calculateInterval(-5)).toBe(1); + }); }); diff --git a/packages/app/src/components/TimelineChart/utils.ts b/packages/app/src/components/TimelineChart/utils.ts index dc1fe17570..998827e997 100644 --- a/packages/app/src/components/TimelineChart/utils.ts +++ b/packages/app/src/components/TimelineChart/utils.ts @@ -18,21 +18,28 @@ export function renderMs(ms: number) { return `${(ms / 1000).toFixed(3)}s`; } -export function calculateInterval(value: number) { - // Calculate the approximate interval by dividing the value by 10 - const interval = value / 10; +// Returns the smallest "human-friendly" interval (a 1/2/5 × 10ⁿ bucket) that +// divides `value` into at most `maxTicks` steps. Snapping up rather than down +// guarantees the resulting tick count never exceeds maxTicks, so callers can +// budget ticks by the available pixel width to avoid overlapping labels. +export function calculateInterval(value: number, maxTicks = 10) { + if (value <= 0 || maxTicks < 1) { + return value > 0 ? value : 1; + } - // Round the interval to the nearest power of 10 to make it a human-friendly number - const magnitude = Math.pow(10, Math.floor(Math.log10(interval))); + // The finest interval that would still fit within the tick budget; snap it up + // to the nearest human-friendly bucket. + const rough = value / maxTicks; + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); - // Adjust the interval to the nearest standard bucket size - let bucketSize = magnitude; - if (interval >= 2 * magnitude) { - bucketSize = 2 * magnitude; + if (rough <= magnitude) { + return magnitude; } - if (interval >= 5 * magnitude) { - bucketSize = 5 * magnitude; + if (rough <= 2 * magnitude) { + return 2 * magnitude; } - - return bucketSize; + if (rough <= 5 * magnitude) { + return 5 * magnitude; + } + return 10 * magnitude; } diff --git a/packages/app/src/components/__tests__/DBTracePanel.test.tsx b/packages/app/src/components/__tests__/DBTracePanel.test.tsx index ca95483eb4..9c67cbe944 100644 --- a/packages/app/src/components/__tests__/DBTracePanel.test.tsx +++ b/packages/app/src/components/__tests__/DBTracePanel.test.tsx @@ -36,6 +36,18 @@ jest.mock('../SourceSelect', () => ({ SourceSelectControlled: () =>
source select
, })); +// useRowData runs unconditionally (for the Infrastructure tab / k8s detection) +// and would otherwise need a QueryClient provider; stub it for this unit test. +jest.mock('../DBRowDataPanel', () => ({ + useRowData: () => ({ data: undefined }), + RowDataPanel: () =>
row data panel
, +})); + +jest.mock('../DBInfraPanel', () => ({ + __esModule: true, + default: () =>
infra panel
, +})); + jest.mock('../SourceSchemaPreview', () => ({ __esModule: true, default: () =>
, diff --git a/packages/app/styles/ResizablePanel.module.scss b/packages/app/styles/ResizablePanel.module.scss index fe44b09a1e..6f04532888 100644 --- a/packages/app/styles/ResizablePanel.module.scss +++ b/packages/app/styles/ResizablePanel.module.scss @@ -20,6 +20,28 @@ } } +.resizeHandleInline { + position: relative; + right: auto; + top: auto; + bottom: auto; + width: 6px; + flex-shrink: 0; + cursor: col-resize; + transition: all 150ms ease; + z-index: 10; + + &:hover, + &:active { + background-color: var(--color-bg-neutral); + } + + &:active { + background-color: var(--color-bg-neutral); + width: 8px; + } +} + .resizeYHandle { position: absolute; bottom: -3px; From 429c694df1190c5661853d7db5a426f52fbaeeb0 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Wed, 3 Jun 2026 10:37:53 +0200 Subject: [PATCH 2/2] update changeset --- .changeset/tough-ads-pay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tough-ads-pay.md b/.changeset/tough-ads-pay.md index ac693cec18..792a3b8c3d 100644 --- a/.changeset/tough-ads-pay.md +++ b/.changeset/tough-ads-pay.md @@ -1,5 +1,5 @@ --- -"@hyperdx/app": patch +"@hyperdx/app": minor --- feat: trace panel inline split detail