diff --git a/packages/app/src/DBChartPage.tsx b/packages/app/src/DBChartPage.tsx index 82f2e5e26d..b77c760263 100644 --- a/packages/app/src/DBChartPage.tsx +++ b/packages/app/src/DBChartPage.tsx @@ -166,7 +166,6 @@ function AIAssistant({ {opened && ( - // eslint-disable-next-line react-hooks/refs
{ - 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/NamespaceDetailsSidePanel.tsx b/packages/app/src/NamespaceDetailsSidePanel.tsx index 5e0e771961..33a59d633d 100644 --- a/packages/app/src/NamespaceDetailsSidePanel.tsx +++ b/packages/app/src/NamespaceDetailsSidePanel.tsx @@ -181,8 +181,6 @@ function NamespaceLogs({ @@ -471,7 +469,6 @@ export default function PodDetailsSidePanel({ rowId={rowId} aliasWith={aliasWith} onClose={handleCloseRowSidePanel} - isNestedPanel={true} /> )}
diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 7440d3002f..78d58da203 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -1,21 +1,43 @@ -import { useMemo, useState } from 'react'; -import CopyToClipboard from 'react-copy-to-clipboard'; +import { useCallback, useMemo, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; import { useHotkeys } from 'react-hotkeys-hook'; import { DateRange, SearchCondition, SearchConditionLanguage, + SourceKind, TSessionSource, + TSource, TTraceSource, } from '@hyperdx/common-utils/dist/types'; -import { ActionIcon, Button, Drawer } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { IconLink, IconX } from '@tabler/icons-react'; +import { Box, Drawer, Flex, Stack, Text } from '@mantine/core'; +import useResizable from '@/hooks/useResizable'; +import { WithClause } from '@/hooks/useRowWhere'; +import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; +import { useZIndex, ZIndexContext } from '@/zIndex'; + +import { + DBRowSidePanelInner, + RowSidePanelContext, + SidePanelHeaderActions, +} from './components/DBRowSidePanel'; +import { getInitialDrawerWidthPercent } from './components/DrawerUtils'; +import SidePanelBreadcrumbs, { + BreadcrumbItem, +} from './components/SidePanelBreadcrumbs'; import { Session } from './sessions'; import SessionSubpanel from './SessionSubpanel'; import { formatDistanceToNowStrictShort } from './utils'; -import { ZIndexContext } from './zIndex'; + +import styles from '../styles/LogSidePanel.module.scss'; + +type SourceStackEntry = { + source: TSource; + rowId: string; + aliasWith?: WithClause[]; + label: string; +}; export default function SessionSidePanel({ traceSource, @@ -30,7 +52,6 @@ export default function SessionSidePanel({ onPropertyAddClick, generateSearchUrl, generateChartUrl, - zIndex = 100, }: { traceSource: TTraceSource; sessionSource: TSessionSource; @@ -48,21 +69,77 @@ export default function SessionSidePanel({ field: string; groupBy: string[]; }) => string; - zIndex?: number; }) { - // Keep track of sub-drawers so we can disable closing this root drawer - const [subDrawerOpen, setSubDrawerOpen] = useState(false); + const contextZIndex = useZIndex(); + const drawerZIndex = contextZIndex + 10; - useHotkeys( - ['esc'], - () => { - onClose(); - }, - { - enabled: subDrawerOpen === false, + const [_subDrawerOpen, setSubDrawerOpen] = useState(false); + const [sourceStack, setSourceStack] = useState([]); + + const initialWidth = getInitialDrawerWidthPercent(); + const { size, setSize, startResize } = useResizable(initialWidth); + + const isFullWidth = size >= 99; + const toggleFullWidth = useCallback(() => { + setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100); + }, [isFullWidth, setSize]); + + const { clear: clearTraceWaterfallSearchState } = useWaterfallSearchState({}); + + const handleClose = useCallback(() => { + clearTraceWaterfallSearchState(); + onClose(); + }, [onClose, clearTraceWaterfallSearchState]); + + const handleNavigateBack = useCallback(() => { + if (sourceStack.length > 0) { + setSourceStack(prev => prev.slice(0, -1)); + } else { + handleClose(); + } + }, [sourceStack.length, handleClose]); + + useHotkeys(['esc'], handleNavigateBack); + + const sessionLabel = session?.userEmail || `Anonymous Session ${sessionId}`; + + const handleEventNavigate = useCallback( + (rowId: string, aliasWith: WithClause[]) => { + setSourceStack(prev => [ + ...prev, + { + source: traceSource as TSource, + rowId, + aliasWith, + label: sessionLabel, + }, + ]); }, + [traceSource, sessionLabel], ); + const activeSourceEntry = + sourceStack.length > 0 ? sourceStack[sourceStack.length - 1] : null; + + const breadcrumbs = useMemo((): BreadcrumbItem[] => { + const items: BreadcrumbItem[] = []; + + if (sourceStack.length > 0) { + items.push({ + label: sessionLabel, + sourceKind: SourceKind.Session, + onClick: () => setSourceStack([]), + }); + } else { + items.push({ + label: sessionLabel, + sourceKind: SourceKind.Session, + }); + } + + return items; + }, [sourceStack.length, sessionLabel]); + const timeAgo = useMemo(() => { const maxTime = // eslint-disable-next-line no-restricted-syntax @@ -74,84 +151,120 @@ export default function SessionSidePanel({ { - if (!subDrawerOpen) { - onClose(); - } + setSubDrawerOpen(false); + handleClose(); }} position="right" - size="82vw" + size={`${size}vw`} withCloseButton={false} - zIndex={zIndex} + withOverlay + closeOnClickOutside + closeOnEscape={false} + lockScroll={false} + zIndex={drawerZIndex} styles={{ + content: { + border: 'none', + boxShadow: 'var(--shadow-drawer)', + }, body: { - padding: 0, - height: '100vh', + padding: '0', + height: '100%', }, }} - className="border-start" > - -
-
-
-
- {session?.userEmail || `Anonymous Session ${sessionId}`} -
- Last active {timeAgo} ago - · - {Number.parseInt(session?.errorCount ?? '0') > 0 ? ( + +
+ + + {activeSourceEntry ? ( + + ( + +
+ An error occurred while rendering this event. +
+
+ {error?.error?.message} +
+
+ )} + > + setSourceStack([])} + setSubDrawerOpen={setSubDrawerOpen} + isFullWidth={isFullWidth} + onToggleFullWidth={toggleFullWidth} + drawerSize={size} + parentBreadcrumbs={[ + { + label: sessionLabel, + sourceKind: SourceKind.Session, + onClick: () => setSourceStack([]), + }, + ]} + /> +
+
+ ) : ( + <> + + + + + + + Last active {timeAgo} ago + {Number.parseInt(session?.errorCount ?? '0') > 0 && ( <> - + {' · '} + {session?.errorCount} Errors - - · + - ) : null} - {session?.sessionCount} Events -
-
-
- { - notifications.show({ - color: 'green', - message: 'Copied link to clipboard', - }); - }} - > - - - - - + )} + {' · '} + {session?.sessionCount} Events + + + +
+
-
-
- {sessionId != null ? ( - - ) : null} + + )}
diff --git a/packages/app/src/SessionSubpanel.tsx b/packages/app/src/SessionSubpanel.tsx index f5a302f162..749b568ff8 100644 --- a/packages/app/src/SessionSubpanel.tsx +++ b/packages/app/src/SessionSubpanel.tsx @@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import cx from 'classnames'; import throttle from 'lodash/throttle'; import { parseAsInteger, useQueryState } from 'nuqs'; -import ReactDOM from 'react-dom'; import { useForm } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -19,7 +18,6 @@ import { Button, Divider, Group, - Portal, SegmentedControl, Tooltip, } from '@mantine/core'; @@ -33,7 +31,6 @@ import { IconToggleRight, } from '@tabler/icons-react'; -import DBRowSidePanel from '@/components/DBRowSidePanel'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; import SearchWhereInput from './components/SearchInput/SearchWhereInput'; @@ -239,6 +236,7 @@ export default function SessionSubpanel({ generateChartUrl, generateSearchUrl, setDrawerOpen, + onEventNavigate, rumSessionId, start, end, @@ -259,6 +257,7 @@ export default function SessionSubpanel({ onPropertyAddClick?: (name: string, value: string) => void; setDrawerOpen: (open: boolean) => void; + onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; rumSessionId: string; start: Date; end: Date; @@ -267,9 +266,6 @@ export default function SessionSubpanel({ whereLanguage?: SearchConditionLanguage; onLanguageChange?: (lang: 'sql' | 'lucene') => void; }) { - const [rowId, setRowId] = useState(undefined); - const [aliasWith, setAliasWith] = useState([]); - const [tsQuery, setTsQuery] = useQueryState( 'ts', parseAsInteger.withOptions({ history: 'replace' }), @@ -441,11 +437,13 @@ export default function SessionSubpanel({ ); const onSessionEventClick = useCallback( (rowWhere: RowWhereResult) => { - setDrawerOpen(true); - setRowId(rowWhere.where); - setAliasWith(rowWhere.aliasWith); + if (onEventNavigate) { + onEventNavigate(rowWhere.where, rowWhere.aliasWith); + } else { + setDrawerOpen(true); + } }, - [setDrawerOpen, setRowId, setAliasWith], + [onEventNavigate, setDrawerOpen], ); const onSessionEventTimeClick = useCallback( (ts: number) => { @@ -464,19 +462,6 @@ export default function SessionSubpanel({ return (
- {rowId != null && traceSource && ( - - { - setDrawerOpen(false); - setRowId(undefined); - }} - /> - - )}
({ items, activeItem, className, + style, onClick, 'data-testid': dataTestId, }: { items: Array<{ value: T; text: React.ReactNode }>; activeItem: T; className?: string | undefined; + style?: React.CSSProperties; onClick?: (item: T) => any; 'data-testid'?: string; }) { return ( -
+
{items.map(item => { return ( ; rowId: string | undefined; - breadcrumbPath?: BreadcrumbPath; - onBreadcrumbClick?: BreadcrumbNavigationCallback; + onNavigateToRow?: ( + rowId: string, + aliasWith: WithClause[], + label: string, + sourceKind?: SourceKind, + ) => void; + 'data-testid'?: string; } -// Custom hook to manage nested panel state export function useNestedPanelState(isNested?: boolean) { - // Query state (URL-based) for root level const queryState = { contextRowId: useQueryState('contextRowId', parseAsStringEncoded), - // Source IDs are MongoDB ObjectIDs (hex strings) and contain no special - // characters, so no encoding is needed here. contextRowSource: useQueryState('contextRowSource'), }; - // Local state for nested levels const localState = { contextRowId: useState(null), contextRowSource: useState(null), }; - // Choose which state to use based on nesting level const activeState = isNested ? localState : queryState; return { @@ -79,8 +72,7 @@ export default function ContextSubpanel({ dbSqlRowTableConfig, rowData, rowId, - breadcrumbPath, - onBreadcrumbClick, + onNavigateToRow, }: ContextSubpanelProps) { const QUERY_KEY_PREFIX = 'context'; const origTimestamp = rowData[ROW_DATA_ALIASES.TIMESTAMP]; @@ -101,36 +93,26 @@ export default function ContextSubpanel({ const formWhere = useWatch({ control, name: 'where' }); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); - // State management for nested panels - const isNested = !!breadcrumbPath?.length; - - const { - contextRowId, - contextRowSource, - setContextRowId, - setContextRowSource, - } = useNestedPanelState(isNested); - - const { data: contextRowSidePanelSource } = useSource({ - id: contextRowSource || '', - }); - - const [contextAliasWith, setContextAliasWith] = useState([]); - - const handleContextSidePanelClose = useCallback(() => { - setContextRowId(null); - setContextRowSource(null); - }, [setContextRowId, setContextRowSource]); - const { setChildModalOpen } = useContext(RowSidePanelContext); const handleRowExpandClick = useCallback( - (rowWhere: RowWhereResult) => { - setContextRowId(rowWhere.where); - setContextAliasWith(rowWhere.aliasWith); - setContextRowSource(source.id); + (rowWhere: RowWhereResult, row: Record) => { + const body = row?.['__hdx_body']; + const fallback = source.kind === SourceKind.Trace ? 'Span' : 'Log'; + const label = + typeof body === 'string' && body.length > 0 + ? body + : body != null + ? JSON.stringify(body) + : fallback; + onNavigateToRow?.( + rowWhere.where, + rowWhere.aliasWith, + label, + source.kind as SourceKind, + ); }, - [source.id, setContextRowId, setContextRowSource], + [onNavigateToRow, source.kind], ); const date = useMemo(() => new Date(origTimestamp), [origTimestamp]); @@ -143,11 +125,6 @@ export default function ContextSubpanel({ [date, range], ); - /* Functions to help generate WHERE clause based on - which Context the user chooses (All, Host, Node, etc...). - Since we support lucene and sql, we need to format the condition - based on the language - */ const { 'k8s.node.name': k8sNodeName, 'k8s.pod.name': k8sPodName, @@ -186,7 +163,6 @@ export default function ContextSubpanel({ [k8sNodeName, k8sPodName, host, service, debouncedWhere], ); - // Main function to generate WHERE clause based on context const getWhereClause = useCallback( (contextBy: ContextBy): string => { const isSql = originalLanguage === 'sql'; @@ -222,18 +198,19 @@ export default function ContextSubpanel({ ]; } + const defaultTableSelectExpr = + source.kind === SourceKind.Log || source.kind === SourceKind.Trace + ? source.defaultTableSelectExpression + : undefined; + const config = useMemo(() => { const whereClause = getWhereClause(contextBy); - // missing query info, build config from source with default value if (!dbSqlRowTableConfig) return { connection: source.connection, from: source.from, timestampValueExpression: source.timestampValueExpression, - select: - ((isLogSource(source) || isTraceSource(source)) && - source.defaultTableSelectExpression) || - '', + select: defaultTableSelectExpr || '', limit: { limit: 200 }, orderBy: `${source.timestampValueExpression} DESC`, where: whereClause, @@ -254,7 +231,10 @@ export default function ContextSubpanel({ originalLanguage, newDateRange, contextBy, - source, + source.connection, + defaultTableSelectExpr, + source.from, + source.timestampValueExpression, ]); return ( @@ -305,7 +285,13 @@ export default function ContextSubpanel({
-
+
)} - {contextRowId && contextRowSidePanelSource && ( - - )} ); } diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index f548a3b71d..04c35db61d 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -953,7 +953,7 @@ function Heatmap({ }, plugins: [ // legendAsTooltipPlugin() - // eslint-disable-next-line react-hooks/refs -- mouseInsideRef is read at event time, not during render + highlightDataPlugin({ proximity: 20, yFormatter: tickFormatter, diff --git a/packages/app/src/components/DBInfraPanel.tsx b/packages/app/src/components/DBInfraPanel.tsx index be434f6f02..b4ebfc51d8 100644 --- a/packages/app/src/components/DBInfraPanel.tsx +++ b/packages/app/src/components/DBInfraPanel.tsx @@ -6,8 +6,6 @@ import { Granularity, } from '@hyperdx/common-utils/dist/core/utils'; import { - isLogSource, - isTraceSource, SourceKind, TMetricSource, TSource, @@ -223,7 +221,7 @@ export default ({ useDisclosure(false); const metricSourceId = - isLogSource(source) || isTraceSource(source) + source.kind === SourceKind.Log || source.kind === SourceKind.Trace ? source.metricSourceId : undefined; const { data: metricSource, isLoading: isLoadingMetricSource } = useSource({ @@ -231,6 +229,8 @@ export default ({ kinds: [SourceKind.Metric], }); + const logSource = source.kind === SourceKind.Log ? source : undefined; + const podUid = rowData?.__hdx_resource_attributes['k8s.pod.uid']; const nodeName = rowData?.__hdx_resource_attributes['k8s.node.name']; @@ -282,7 +282,7 @@ export default ({ metricSource={metricSource} /> )} - {source && source.kind === SourceKind.Log && ( + {logSource && ( Pod Timeline @@ -295,7 +295,7 @@ export default ({ > { if (!queryResult.data?.data?.[0]) { return queryResult.data; @@ -192,8 +219,6 @@ export function useRowData({ export function getJSONColumnNames(meta: ResponseJSON['meta'] | undefined) { return ( meta - // The type could either be just 'JSON' or it could be 'JSON()' - // this is a basic way to match both cases ?.filter(m => m.type === 'JSON' || m.type.startsWith('JSON(')) .map(m => m.name) ?? [] ); diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index f1eac9fa43..92f76ea7e3 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -373,12 +373,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,20 +511,8 @@ export function DBRowJsonViewer({ const isIncluded = displayedColumns?.includes(columnFieldPath); actions.push({ key: 'toggle-column', - label: isIncluded ? ( - - - Column - - ) : ( - - - Column - - ), - title: isIncluded - ? `Remove ${fieldPath} column from results table` - : `Add ${fieldPath} column to results table`, + label: isIncluded ? : , + title: isIncluded ? 'Remove Column' : 'Add Column', onClick: () => { toggleColumn(columnFieldPath); notifications.show({ @@ -571,18 +549,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: () => { window.navigator.clipboard.writeText( typeof value === 'string' diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index adf027db4f..b613bb85a8 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -4,7 +4,9 @@ import { SetStateAction, useCallback, useContext, + useEffect, useMemo, + useRef, useState, } from 'react'; import { add } from 'date-fns'; @@ -12,39 +14,52 @@ import { isString } from 'lodash'; import { parseAsStringEnum, useQueryState } from 'nuqs'; import { ErrorBoundary } from 'react-error-boundary'; import { useHotkeys } from 'react-hotkeys-hook'; -import { - isLogSource, - isSessionSource, - isTraceSource, - SourceKind, - TLogSource, - TSource, - TTraceSource, -} from '@hyperdx/common-utils/dist/types'; +import SqlString from 'sqlstring'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; -import { Box, Drawer, Flex, Stack } from '@mantine/core'; +import { + ActionIcon, + Badge, + Box, + Button, + CopyButton, + Drawer, + Flex, + Group, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { + IconCopy, + IconKeyboard, + IconLayoutSidebarRightCollapse, + IconLayoutSidebarRightExpand, + IconShare, + IconX, +} from '@tabler/icons-react'; -import DBRowSidePanelHeader, { - BreadcrumbNavigationCallback, - BreadcrumbPath, -} from '@/components/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 { KeyboardShortcutsModal } from '@/LogSidePanelElements'; +import { useSource } from '@/source'; import TabBar from '@/TabBar'; import { SearchConfig } from '@/types'; +import { FormatTime } from '@/useFormatTime'; +import { formatDistanceToNowStrictShort } from '@/utils'; import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes'; import { useZIndex, ZIndexContext } from '@/zIndex'; import ServiceMapSidePanel from './ServiceMap/ServiceMapSidePanel'; import ContextSubpanel from './ContextSidePanel'; -import DBInfraPanel from './DBInfraPanel'; -import { RowDataPanel, useRowData } from './DBRowDataPanel'; +import { DBHighlightedAttributesList } from './DBHighlightedAttributesList'; +import { ROW_DATA_ALIASES, RowDataPanel, useRowData } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; import { DBSessionPanel, useSessionId } from './DBSessionPanel'; import DBTracePanel from './DBTracePanel'; +import { getInitialDrawerWidthPercent } from './DrawerUtils'; +import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -74,7 +89,7 @@ export type RowSidePanelContextProps = { dbSqlRowTableConfig?: BuilderChartConfigWithDateRange; isChildModalOpen?: boolean; setChildModalOpen?: (open: boolean) => void; - source?: TLogSource | TTraceSource; + source?: TSource; }; export const RowSidePanelContext = createContext({}); @@ -87,112 +102,276 @@ enum Tab { ServiceMap = 'serviceMap', Context = 'context', Replay = 'replay', - Infrastructure = 'infrastructure', } +export function SidePanelHeaderActions({ + onClose, + isFullWidth, + onToggleFullWidth, +}: { + onClose: () => void; + isFullWidth?: boolean; + onToggleFullWidth?: () => void; +}) { + const [shortcutsOpen, setShortcutsOpen] = useState(false); + + return ( + <> + + {onToggleFullWidth && ( + + + {isFullWidth ? ( + + ) : ( + + )} + + + )} + + setShortcutsOpen(true)} + aria-label="Keyboard shortcuts" + > + + + + + {({ copied, copy }) => ( + + + + + + )} + + + + + + + + setShortcutsOpen(false)} + /> + + ); +} + +type NavEntry = { + rowId: string; + aliasWith: WithClause[]; + label: string; + sourceKind?: SourceKind; +}; + +type SourceStackEntry = { + source: TSource; + rowId: string; + aliasWith?: WithClause[]; + label: string; + sourceKind?: SourceKind; +}; + type DBRowSidePanelProps = { source: TSource; rowId: string | undefined; aliasWith?: WithClause[]; onClose: () => void; - isNestedPanel?: boolean; - breadcrumbPath?: BreadcrumbPath; - onBreadcrumbClick?: BreadcrumbNavigationCallback; + initialTab?: `${Tab}`; }; -const DBRowSidePanel = ({ - rowId: rowId, - aliasWith, - source, - isNestedPanel = false, +export type DBRowSidePanelInnerProps = DBRowSidePanelProps & { + setSubDrawerOpen: Dispatch>; + isFullWidth?: boolean; + onToggleFullWidth?: () => void; + drawerSize?: number; + parentBreadcrumbs?: BreadcrumbItem[]; + onNavigateToParent?: () => void; +}; + +export const DBRowSidePanelInner = ({ + rowId: initialRowId, + aliasWith: initialAliasWith, + source: rootSource, setSubDrawerOpen, onClose, - breadcrumbPath, - onBreadcrumbClick, -}: DBRowSidePanelProps & { - setSubDrawerOpen: Dispatch>; -}) => { + initialTab, + isFullWidth, + onToggleFullWidth, + drawerSize: _drawerSize, + parentBreadcrumbs, + onNavigateToParent, +}: DBRowSidePanelInnerProps) => { + const [sourceStack, setSourceStack] = useState([]); + const [navStack, setNavStack] = useState([]); + + const activeSourceEntry = + sourceStack.length > 0 ? sourceStack[sourceStack.length - 1] : null; + const source = activeSourceEntry?.source ?? rootSource; + + const baseRowId = activeSourceEntry?.rowId ?? initialRowId; + const baseAliasWith = activeSourceEntry?.aliasWith ?? initialAliasWith; + + const activeRowId = + navStack.length > 0 ? navStack[navStack.length - 1].rowId : baseRowId; + const activeAliasWith = + navStack.length > 0 + ? navStack[navStack.length - 1].aliasWith + : baseAliasWith; + + const handleNavigateToRow = useCallback( + ( + rowId: string, + aliasWith: WithClause[], + label: string, + sourceKind?: SourceKind, + ) => { + setNavStack(prev => [...prev, { rowId, aliasWith, label, sourceKind }]); + }, + [], + ); + + const handlePanelBack = useCallback(() => { + if (navStack.length > 0) { + setNavStack(prev => prev.slice(0, -1)); + } else if (sourceStack.length > 0) { + setSourceStack(prev => prev.slice(0, -1)); + setNavStack([]); + } else if (onNavigateToParent) { + onNavigateToParent(); + } else { + onClose(); + } + }, [navStack.length, sourceStack.length, onNavigateToParent, onClose]); + + const handleSourceStackPush = useCallback((entry: SourceStackEntry) => { + setSourceStack(prev => [...prev, entry]); + setNavStack([]); + }, []); + + const handleBreadcrumbNavigation = useCallback( + (sourceLevel: number, navLevel: number) => { + setSourceStack(prev => prev.slice(0, sourceLevel)); + setNavStack(prev => prev.slice(0, navLevel)); + }, + [], + ); + const { data: rowData, isLoading: isRowLoading, isSuccess: isRowSuccess, } = useRowData({ source, - rowId, - aliasWith, + rowId: activeRowId, + aliasWith: activeAliasWith, }); - const { dbSqlRowTableConfig } = useContext(RowSidePanelContext); - - const handleBreadcrumbClick = useCallback( - (targetLevel: number) => { - // Current panel's level in the hierarchy - const currentLevel = breadcrumbPath?.length ?? 0; - - // The target panel level corresponds to the breadcrumb index: - // - targetLevel 0 = root panel (breadcrumbPath.length = 0) - // - targetLevel 1 = first nested panel (breadcrumbPath.length = 1) - // - etc. - - // If our current level is greater than the target panel level, close this panel - if (currentLevel > targetLevel) { - onClose(); - onBreadcrumbClick?.(targetLevel); - } - // If our current level equals the target panel level, we're the target - don't close - else if (currentLevel === targetLevel) { - // This is the panel the user wants to navigate to - do nothing (stay open) - return; - } - // If our current level is less than target, propagate up (this panel should stay open) - else { - onBreadcrumbClick?.(targetLevel); - } - }, - [breadcrumbPath?.length, onBreadcrumbClick, onClose], - ); + const parentContext = useContext(RowSidePanelContext); + const dbSqlRowTableConfig = + sourceStack.length > 0 ? undefined : parentContext.dbSqlRowTableConfig; const hasOverviewPanel = useMemo(() => { - if (isLogSource(source) || isTraceSource(source)) { - if ( - source.resourceAttributesExpression || - source.eventAttributesExpression - ) { - return true; - } - } else if ( - source.kind === SourceKind.Metric && - source.resourceAttributesExpression + if (source.resourceAttributesExpression) { + return true; + } + if ( + (source.kind === SourceKind.Log || source.kind === SourceKind.Trace) && + source.eventAttributesExpression ) { return true; } return false; }, [source]); + const isTraceSource = source.kind === 'trace'; + const defaultTab = - source.kind === 'trace' - ? Tab.Trace - : hasOverviewPanel - ? Tab.Overview - : Tab.Parsed; + (sourceStack.length === 0 ? (initialTab as Tab) : undefined) ?? + (isTraceSource ? Tab.Trace : hasOverviewPanel ? Tab.Overview : Tab.Parsed); const [queryTab, setQueryTab] = useQueryState( 'sidePanelTab', parseAsStringEnum(Object.values(Tab)).withDefault(defaultTab), ); - const [stateTab, setStateTab] = useState(defaultTab); - // Nested panels can't share the query param or else they'll conflict, so we'll use local state for nested panels - // We'll need to handle this properly eventually... - const tab = isNestedPanel ? stateTab : queryTab; - const setTab = isNestedPanel ? setStateTab : setQueryTab; + const prevSourceStackLengthRef = useRef(sourceStack.length); + const prevNavStackLengthRef = useRef(navStack.length); + useEffect(() => { + const sourceChanged = + sourceStack.length !== prevSourceStackLengthRef.current; + const navPushed = navStack.length > prevNavStackLengthRef.current; + + if (sourceChanged && sourceStack.length > 0) { + const newSource = sourceStack[sourceStack.length - 1].source; + const newIsTrace = newSource.kind === 'trace'; + const newDefault = newIsTrace ? Tab.Trace : Tab.Overview; + setQueryTab(newDefault); + } else if (sourceChanged && prevSourceStackLengthRef.current > 0) { + const rootIsTrace = rootSource.kind === 'trace'; + const rootDefault = rootIsTrace + ? Tab.Trace + : hasOverviewPanel + ? Tab.Overview + : Tab.Parsed; + setQueryTab(rootDefault); + } else if (navPushed) { + const currentIsTrace = source.kind === 'trace'; + const navDefault = currentIsTrace + ? Tab.Trace + : hasOverviewPanel + ? Tab.Overview + : Tab.Parsed; + setQueryTab(navDefault); + } + + prevSourceStackLengthRef.current = sourceStack.length; + prevNavStackLengthRef.current = navStack.length; + }, [ + sourceStack, + navStack, + setQueryTab, + rootSource.kind, + source.kind, + hasOverviewPanel, + ]); - const displayedTab = tab; + const displayedTab = queryTab; + const setTab = setQueryTab; const normalizedRow = rowData?.data?.[0]; const timestampValue = normalizedRow?.['__hdx_timestamp']; - // TODO: Improve parsing let timestampDate: Date; if (typeof timestampValue === 'number') { timestampDate = new Date(timestampValue * 1000); @@ -200,35 +379,37 @@ const DBRowSidePanel = ({ timestampDate = new Date(timestampValue); } - const mainContentColumn = getEventBody(source); const mainContent = isString(normalizedRow?.['__hdx_body']) ? normalizedRow['__hdx_body'] : normalizedRow?.['__hdx_body'] !== undefined ? JSON.stringify(normalizedRow['__hdx_body']) : undefined; - const severityText: string | undefined = - normalizedRow?.['__hdx_severity_text']; - const highlightedAttributeValues = useMemo(() => { - const attributeExpressions: NonNullable< - (TLogSource | TTraceSource)['highlightedRowAttributeExpressions'] - > = []; + const [initialMainContent, setInitialMainContent] = useState< + string | undefined + >(undefined); + useEffect(() => { if ( - (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) && - source.highlightedRowAttributeExpressions + mainContent != null && + initialMainContent == null && + sourceStack.length === 0 && + navStack.length === 0 ) { - attributeExpressions.push(...source.highlightedRowAttributeExpressions); + setInitialMainContent(mainContent); } + }, [mainContent, initialMainContent, sourceStack.length, navStack.length]); - // Add service name expression to all sources, to maintain compatibility with - // the behavior prior to the addition of highlightedRowAttributeExpressions + const highlightedAttributeValues = useMemo(() => { + const attributeExpressions: Array<{ + sqlExpression: string; + luceneExpression?: string; + alias?: string; + }> = []; if ( - (isLogSource(source) || isTraceSource(source)) && - source.serviceNameExpression + (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) && + source.highlightedRowAttributeExpressions ) { - attributeExpressions.push({ - sqlExpression: source.serviceNameExpression, - }); + attributeExpressions.push(...source.highlightedRowAttributeExpressions); } return rowData @@ -248,7 +429,6 @@ const DBRowSidePanel = ({ ] as [Date, Date]; }, [timestampDate]); - // For session replay, we need +/-4 hours to get full session const fourHourRange = useMemo(() => { return [ add(timestampDate, { hours: -4 }), @@ -257,48 +437,61 @@ const DBRowSidePanel = ({ }, [timestampDate]); const focusDate = timestampDate; - const traceId: string | undefined = normalizedRow?.['__hdx_trace_id']; + const traceId: string | undefined = + normalizedRow?.['__hdx_trace_id'] || undefined; - const childSourceId = isLogSource(source) - ? source.traceSourceId - : isTraceSource(source) - ? source.logSourceId - : undefined; - - const traceSourceId = isTraceSource(source) - ? source.id - : isLogSource(source) + const childSourceId = + source.kind === 'log' ? source.traceSourceId - : isSessionSource(source) + : source.kind === 'trace' + ? source.logSourceId + : undefined; + + const traceSourceId = + source.kind === SourceKind.Trace + ? source.id + : source.kind === SourceKind.Log || source.kind === SourceKind.Session ? source.traceSourceId : undefined; const enableServiceMap = traceId && traceSourceId; + const { data: traceSourceData } = useSource({ id: traceSourceId }); + + const spanId = normalizedRow?.['__hdx_span_id']; + const spanIdExpression = + traceSourceData?.kind === SourceKind.Log || + traceSourceData?.kind === SourceKind.Trace + ? traceSourceData.spanIdExpression + : undefined; + + const traceSpanRowId = useMemo(() => { + if (!spanIdExpression || !spanId) return undefined; + return SqlString.format('?=?', [SqlString.raw(spanIdExpression), spanId]); + }, [spanIdExpression, spanId]); + + const handleSessionEventNavigate = useCallback( + (rowId: string, aliasWith: WithClause[]) => { + if (traceSourceData) { + handleSourceStackPush({ + source: traceSourceData, + rowId, + aliasWith, + label: mainContent || 'Session Replay', + sourceKind: source.kind as SourceKind, + }); + } + }, + [traceSourceData, handleSourceStackPush, mainContent, source.kind], + ); + const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, dateRange: oneHourRange, - enabled: rowId != null, + enabled: activeRowId != null, }); - const hasK8sContext = useMemo(() => { - try { - if (!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 initialRowHighlightHint = useMemo(() => { if (normalizedRow) { return { @@ -309,6 +502,112 @@ const DBRowSidePanel = ({ } }, [normalizedRow]); + const durationMs = normalizedRow?.[ROW_DATA_ALIASES.DURATION_MS]; + const spanKind = normalizedRow?.[ROW_DATA_ALIASES.SPAN_KIND]; + const serviceName = normalizedRow?.[ROW_DATA_ALIASES.SERVICE_NAME]; + const statusCode = normalizedRow?.[ROW_DATA_ALIASES.SEVERITY_TEXT]; + + const formattedDuration = useMemo(() => { + if (durationMs == null || isNaN(Number(durationMs))) return undefined; + const ms = Number(durationMs); + if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`; + if (ms < 1000) return `${ms.toFixed(2)}ms`; + return `${(ms / 1000).toFixed(2)}s`; + }, [durationMs]); + + const spanKindLabel = useMemo(() => { + if (spanKind == null) return undefined; + const kindMap: Record = { + '1': 'Internal', + '2': 'Server', + '3': 'Client', + '4': 'Producer', + '5': 'Consumer', + Internal: 'Internal', + Server: 'Server', + Client: 'Client', + Producer: 'Producer', + Consumer: 'Consumer', + SPAN_KIND_INTERNAL: 'Internal', + SPAN_KIND_SERVER: 'Server', + SPAN_KIND_CLIENT: 'Client', + SPAN_KIND_PRODUCER: 'Producer', + SPAN_KIND_CONSUMER: 'Consumer', + }; + return kindMap[String(spanKind)] ?? String(spanKind); + }, [spanKind]); + + const allBreadcrumbs = useMemo((): BreadcrumbItem[] => { + const items: BreadcrumbItem[] = []; + + if (parentBreadcrumbs) { + items.push(...parentBreadcrumbs); + } + + const rootLabel = + initialMainContent || (rootSource.kind === 'trace' ? 'Trace' : 'Log'); + if (sourceStack.length > 0 || navStack.length > 0) { + items.push({ + label: rootLabel, + sourceKind: rootSource.kind as SourceKind, + onClick: () => handleBreadcrumbNavigation(0, 0), + }); + } + + sourceStack.forEach((entry, i) => { + const isLastSource = i === sourceStack.length - 1; + if (isLastSource && navStack.length === 0) { + items.push({ + label: entry.label, + sourceKind: entry.source.kind as SourceKind, + }); + } else { + items.push({ + label: entry.label, + sourceKind: entry.source.kind as SourceKind, + onClick: () => handleBreadcrumbNavigation(i + 1, 0), + }); + } + }); + + if (navStack.length > 0) { + navStack.forEach((entry, i) => { + if (i < navStack.length - 1) { + items.push({ + label: entry.label, + sourceKind: entry.sourceKind, + onClick: () => + handleBreadcrumbNavigation(sourceStack.length, i + 1), + }); + } else { + items.push({ + label: entry.label, + sourceKind: entry.sourceKind, + }); + } + }); + } + + if (sourceStack.length === 0 && navStack.length === 0) { + items.push({ + label: mainContent || (isTraceSource ? 'Trace' : 'Log'), + sourceKind: source.kind as SourceKind, + }); + } + + return items; + }, [ + sourceStack, + navStack, + rootSource.kind, + isTraceSource, + mainContent, + initialMainContent, + source.kind, + handleBreadcrumbNavigation, + parentBreadcrumbs, + ]); + if (isRowLoading) { return
Loading...
; } @@ -319,30 +618,170 @@ const DBRowSidePanel = ({ return ( <> - - + + + + + + + {timestampDate && !isNaN(timestampDate.getTime()) && ( + + ·{' '} + {formatDistanceToNowStrictShort(timestampDate)} ago + + )} + {serviceName && ( + <> + + · + + + + Service + + + {serviceName} + + + + )} + {isTraceSource && formattedDuration && ( + <> + + · + + + + Duration + + + {formattedDuration} + + + + )} + {isTraceSource && statusCode && ( + <> + + · + + + + Status + + + {statusCode} + + + + )} + {isTraceSource && spanKindLabel && ( + + {spanKindLabel} + + )} + {isTraceSource && traceId && ( + <> + + · + + + {({ copied, copy }) => ( + + + + + Trace ID + + + + )} + + + )} + {!isTraceSource && traceId && traceSourceId && ( + <> + + · + + + {({ copied, copy }) => ( + + + + + Trace ID + + + + )} + + + + )} + + {highlightedAttributeValues.length > 0 && ( + + + + )} - {/* */} setTab(v)} @@ -404,8 +843,8 @@ const DBRowSidePanel = ({ @@ -421,17 +860,16 @@ const DBRowSidePanel = ({
)} > - - - + )} {displayedTab === Tab.ServiceMap && enableServiceMap && ( @@ -468,8 +906,8 @@ const DBRowSidePanel = ({ )} @@ -489,9 +927,8 @@ const DBRowSidePanel = ({ source={source} dbSqlRowTableConfig={dbSqlRowTableConfig} rowData={normalizedRow} - rowId={rowId} - breadcrumbPath={breadcrumbPath} - onBreadcrumbClick={handleBreadcrumbClick} + rowId={activeRowId} + onNavigateToRow={handleNavigateToRow} /> )} @@ -512,6 +949,7 @@ const DBRowSidePanel = ({ dateRange={fourHourRange} focusDate={focusDate} setSubDrawerOpen={setSubDrawerOpen} + onEventNavigate={handleSessionEventNavigate} traceSourceId={traceSourceId} serviceName={rumServiceName} rumSessionId={rumSessionId} @@ -519,28 +957,6 @@ const DBRowSidePanel = ({
)} - {displayedTab === Tab.Infrastructure && ( - { - console.error(err); - }} - fallbackRender={() => ( -
- An error occurred while rendering this event. -
- )} - > - - - -
- )} - ); }; @@ -550,39 +966,41 @@ export default function DBRowSidePanelErrorBoundary({ rowId, aliasWith, source, - isNestedPanel, - breadcrumbPath, - onBreadcrumbClick, + initialTab, }: DBRowSidePanelProps) { const contextZIndex = useZIndex(); const drawerZIndex = contextZIndex + 10; - const initialWidth = 80; - const { size, startResize } = useResizable(initialWidth); + const initialWidth = getInitialDrawerWidthPercent(); + const { size, setSize, startResize } = useResizable(initialWidth); + + const isFullWidth = size >= 99; + const toggleFullWidth = useCallback(() => { + setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100); + }, [isFullWidth, setSize]); - // Keep track of sub-drawers so we can disable closing this root drawer const [subDrawerOpen, setSubDrawerOpen] = useState(false); - const { isChildModalOpen } = useContext(RowSidePanelContext); + const parentContext = useContext(RowSidePanelContext); - const [_, setQueryTab] = useQueryState( - 'tab', + const [_sidePanelTab, setSidePanelTab] = useQueryState( + 'sidePanelTab', parseAsStringEnum(Object.values(Tab)), ); + useEffect(() => { + if (rowId != null) { + setSidePanelTab(null); + } + }, [rowId, setSidePanelTab]); + const { clear: clearTraceWaterfallSearchState } = useWaterfallSearchState({}); const _onClose = useCallback(() => { - // Reset tab to undefined when unmounting, so that when we open the drawer again, it doesn't open to the last tab - // (which might not be valid, ex session replay) - if (!isNestedPanel) { - setQueryTab(null); - } - // Clear waterfall search state on close, so that filters don't - // persist when reopening another trace. + setSidePanelTab(null); clearTraceWaterfallSearchState(); onClose(); - }, [setQueryTab, isNestedPanel, onClose, clearTraceWaterfallSearchState]); + }, [setSidePanelTab, onClose, clearTraceWaterfallSearchState]); useHotkeys(['esc'], _onClose, { enabled: subDrawerOpen === false }); @@ -590,7 +1008,8 @@ export default function DBRowSidePanelErrorBoundary({ { if (!subDrawerOpen) { _onClose(); @@ -599,6 +1018,10 @@ export default function DBRowSidePanelErrorBoundary({ position="right" size={`${size}vw`} styles={{ + content: { + border: 'none', + boxShadow: 'var(--shadow-drawer)', + }, body: { padding: '0', height: '100%', @@ -607,33 +1030,42 @@ export default function DBRowSidePanelErrorBoundary({ zIndex={drawerZIndex} > -
- - ( - -
- An error occurred while rendering this event. -
- -
- {error?.error?.message} -
-
- )} - > - -
-
+ +
+ + ( + +
+ An error occurred while rendering this event. +
+ +
+ {error?.error?.message} +
+
+ )} + > + +
+
+
); diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index d8cdc61a85..70b13281df 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -1486,7 +1486,10 @@ function DBSqlRowTableComponent({ }: { config: BuilderChartConfigWithDateRange; sourceId?: string; - onRowDetailsClick?: (rowWhere: RowWhereResult) => void; + onRowDetailsClick?: ( + rowWhere: RowWhereResult, + row: Record, + ) => void; highlightedLineId?: string; queryKeyPrefix?: string; enabled?: boolean; @@ -1662,7 +1665,7 @@ function DBSqlRowTableComponent({ const _onRowDetailsClick = useCallback( (row: Record) => { - return onRowDetailsClick?.(getRowWhere(row)); + return onRowDetailsClick?.(getRowWhere(row), row); }, [onRowDetailsClick, getRowWhere], ); diff --git a/packages/app/src/components/DBSessionPanel.tsx b/packages/app/src/components/DBSessionPanel.tsx index b248fd35f3..7751961df1 100644 --- a/packages/app/src/components/DBSessionPanel.tsx +++ b/packages/app/src/components/DBSessionPanel.tsx @@ -94,6 +94,7 @@ export const DBSessionPanel = ({ focusDate, serviceName, setSubDrawerOpen, + onEventNavigate, }: { traceSourceId?: string; rumSessionId: string; @@ -101,6 +102,10 @@ export const DBSessionPanel = ({ focusDate: Date; serviceName: string; setSubDrawerOpen: (open: boolean) => void; + onEventNavigate?: ( + rowId: string, + aliasWith: import('@/hooks/useRowWhere').WithClause[], + ) => void; }) => { const { data: traceSource } = useSource({ id: traceSourceId, @@ -137,6 +142,7 @@ export const DBSessionPanel = ({ sessionSource={sessionSource} rumSessionId={rumSessionId} setDrawerOpen={setSubDrawerOpen} + onEventNavigate={onEventNavigate} initialTs={focusDate.getTime()} /> ) : ( diff --git a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx index 718a43cbd5..5b015c06e3 100644 --- a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx +++ b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx @@ -13,14 +13,12 @@ import TabBar from '@/TabBar'; import { useLocalStorage } from '@/utils'; import { parseAsStringEncoded } from '@/utils/queryParsers'; -import { useNestedPanelState } from './ContextSidePanel'; import { RowDataPanel } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; import DBRowSidePanel, { RowSidePanelContext, RowSidePanelContextProps, } from './DBRowSidePanel'; -import { BreadcrumbEntry } from './DBRowSidePanelHeader'; import { DBRowTableVariant, DBSqlRowTable } from './DBRowTable'; interface Props { @@ -37,8 +35,6 @@ interface Props { queryKeyPrefix?: string; denoiseResults?: boolean; collapseAllRows?: boolean; - isNestedPanel?: boolean; - breadcrumbPath?: BreadcrumbEntry[]; onSortingChange?: (v: SortingState | null) => void; initialSortBy?: SortingState; variant?: DBRowTableVariant; @@ -56,8 +52,6 @@ export default function DBSqlRowTableWithSideBar({ collapseAllRows, isLive, enabled, - isNestedPanel, - breadcrumbPath, onSidebarOpen, onSortingChange, initialSortBy, @@ -68,7 +62,6 @@ export default function DBSqlRowTableWithSideBar({ const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded); const [rowSource, setRowSource] = useQueryState('rowSource'); const [aliasWith, setAliasWith] = useState([]); - const { setContextRowId, setContextRowSource } = useNestedPanelState(); const onOpenSidebar = useCallback( (rowWhere: RowWhereResult) => { @@ -83,19 +76,7 @@ export default function DBSqlRowTableWithSideBar({ const onCloseSidebar = useCallback(() => { setRowId(null); setRowSource(null); - // When closing the main drawer, clear the nested panel state - // this ensures that re-opening the main drawer will not open the nested panel - if (!isNestedPanel) { - setContextRowId(null); - setContextRowSource(null); - } - }, [ - setRowId, - setRowSource, - isNestedPanel, - setContextRowId, - setContextRowSource, - ]); + }, [setRowId, setRowSource]); const renderRowDetails = useCallback( (r: { id: string; aliasWith?: WithClause[]; [key: string]: unknown }) => { if (!sourceData) { @@ -119,8 +100,6 @@ export default function DBSqlRowTableWithSideBar({ source={sourceData} rowId={rowId ?? undefined} aliasWith={aliasWith} - isNestedPanel={isNestedPanel} - breadcrumbPath={breadcrumbPath} onClose={onCloseSidebar} /> )} diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 78c5d17bd7..6688148c42 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -1,45 +1,44 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; import { - isLogSource, - isTraceSource, - SourceKind, -} from '@hyperdx/common-utils/dist/types'; -import { + ActionIcon, + Box, Button, - Center, - Divider, Flex, Group, - Paper, - Stack, Text, + Tooltip, } from '@mantine/core'; -import { IconPencil } from '@tabler/icons-react'; +import { IconX } from '@tabler/icons-react'; -import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallChart'; -import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; +import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import { useSource, useUpdateSource } from '@/source'; import TabBar from '@/TabBar'; import { parseAsJsonEncoded } from '@/utils/queryParsers'; -import { RowDataPanel } from './DBRowDataPanel'; +import { SQLInlineEditorControlled } from './SQLEditor/SQLInlineEditor'; +import DBInfraPanel from './DBInfraPanel'; +import { RowDataPanel, useRowData } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; -import SourceSchemaPreview from './SourceSchemaPreview'; +import { DBTraceWaterfallChartContainer } from './DBTraceWaterfallChart'; import { SourceSelectControlled } from './SourceSelect'; +import resizeStyles from '@/../styles/ResizablePanel.module.scss'; + const eventRowWhereParser = parseAsJsonEncoded<{ id: string; type: string; aliasWith: WithClause[]; }>(); -enum Tab { +enum SpanDetailTab { Overview = 'overview', Parsed = 'parsed', + Infrastructure = 'infrastructure', } export default function DBTracePanel({ @@ -48,17 +47,16 @@ export default function DBTracePanel({ dateRange, focusDate, parentSourceId, + parentSource, initialRowHighlightHint, 'data-testid': dataTestId, }: { parentSourceId?: string | null; childSourceId?: string | null; + parentSource?: TSource; traceId?: string; dateRange: [Date, Date]; focusDate: Date; - // Passed in from side panel to try to identify which - // span in the chart to highlight first without constructing - // a full row where clause initialRowHighlightHint?: { timestamp: string; spanId: string; @@ -66,14 +64,20 @@ export default function DBTracePanel({ }; 'data-testid'?: string; }) { - const { control } = useForm({ + const { control, setValue: setSourceFieldValue } = useForm({ defaultValues: { - source: childSourceId, + source: childSourceId ?? undefined, }, }); const sourceId = useWatch({ control, name: 'source' }); + useEffect(() => { + if (childSourceId != null) { + setSourceFieldValue('source', childSourceId); + } + }, [childSourceId, setSourceFieldValue]); + const { data: childSourceData, isLoading: isChildSourceDataLoading } = useSource({ id: sourceId, @@ -111,99 +115,134 @@ export default function DBTracePanel({ eventRowWhereParser, ); + const parentTraceIdExpr = + parentSourceData?.kind === SourceKind.Log || + parentSourceData?.kind === SourceKind.Trace + ? parentSourceData.traceIdExpression + : undefined; + const { control: traceIdControl, handleSubmit: traceIdHandleSubmit, setValue: traceIdSetValue, } = useForm<{ traceIdExpression: string }>({ defaultValues: { - traceIdExpression: - (parentSourceData && - (isLogSource(parentSourceData) || isTraceSource(parentSourceData)) && - parentSourceData.traceIdExpression) || - '', + traceIdExpression: parentTraceIdExpr ?? '', }, }); useEffect(() => { - if ( - parentSourceData && - (isLogSource(parentSourceData) || isTraceSource(parentSourceData)) && - parentSourceData.traceIdExpression - ) { - traceIdSetValue('traceIdExpression', parentSourceData.traceIdExpression); + if (parentTraceIdExpr) { + traceIdSetValue('traceIdExpression', parentTraceIdExpr); } - }, [parentSourceData, traceIdSetValue]); + }, [parentTraceIdExpr, traceIdSetValue]); const [showTraceIdInput, setShowTraceIdInput] = useState(false); - // Reset highlighted row when trace ID changes - // otherwise we'll show stale span details + const prevTraceIdRef = useRef(undefined); useEffect(() => { - return () => { - setEventRowWhere(null); - }; + const prev = prevTraceIdRef.current; + if (prev !== undefined && prev !== traceId) { + void setEventRowWhere(null); + } + prevTraceIdRef.current = traceId; }, [traceId, setEventRowWhere]); - const sourceSchemaPreview = useMemo(() => { - return ; - }, [childSourceData]); + const [displayedTab, setDisplayedTab] = useState( + SpanDetailTab.Overview, + ); + + const [highlightedSpanId, setHighlightedSpanId] = useState( + null, + ); + + const handleSpanClick = useCallback( + (rowWhere: { id: string; type: string; aliasWith: WithClause[] }) => { + setHighlightedSpanId(rowWhere.id); + setEventRowWhere(rowWhere); + }, + [setEventRowWhere], + ); + + const handleCloseSpanDetails = useCallback(() => { + setEventRowWhere(null); + }, [setEventRowWhere]); + + const { size: rightPanelSize, startResize: startHorizontalResize } = + useResizable(35, 'right'); + + const selectedSpanSource = useMemo(() => { + if (!eventRowWhere) return null; + if (eventRowWhere.type === SourceKind.Log && logSourceData) { + return logSourceData; + } + return traceSourceData; + }, [eventRowWhere, logSourceData, traceSourceData]); + + const { data: selectedSpanRowData } = useRowData({ + source: selectedSpanSource ?? ({} as TSource), + rowId: eventRowWhere?.id, + aliasWith: eventRowWhere?.aliasWith, + }); + + const selectedSpanNormalizedRow = selectedSpanRowData?.data?.[0]; + + const hasSelectedSpanK8sContext = useMemo(() => { + try { + if (!selectedSpanSource?.resourceAttributesExpression) return false; + if (!selectedSpanNormalizedRow) return false; + const resourceAttrs = + selectedSpanNormalizedRow['__hdx_resource_attributes']; + return ( + resourceAttrs?.['k8s.pod.uid'] != null || + resourceAttrs?.['k8s.node.name'] != null + ); + } catch { + return false; + } + }, [selectedSpanSource, selectedSpanNormalizedRow]); - const [displayedTab, setDisplayedTab] = useState(Tab.Overview); return ( -
- - - - {parentSourceData && - (isLogSource(parentSourceData) || isTraceSource(parentSourceData)) - ? parentSourceData.traceIdExpression - : ''} - : {traceId || 'No trace id found for event'} - - {traceId != null && ( - - )} - - - - {parentSourceData?.kind === SourceKind.Log - ? 'Trace Source' - : 'Correlated Log Source'} - - - - - {(showTraceIdInput || !traceId) && parentSourceId != null && ( - - Trace ID Expression - +
+ {/* Left column: Trace ID header + Waterfall chart */} +
+ {(showTraceIdInput || !traceId) && parentSourceId != null && ( + + + Trace ID Expression + - + + - - )} - - {traceSourceData?.kind === SourceKind.Trace && traceId && ( - + + Log source + + + + } + /> + )} +
+ + {/* Resize handle */} + {eventRowWhere != null && ( + )} - {traceSourceData != null && eventRowWhere != null && ( - <> - - Event Details - - setDisplayedTab(v)} - /> - {displayedTab === Tab.Overview && ( + + {/* Right column: Span details */} + {eventRowWhere != null && ( +
+
+ setDisplayedTab(v)} + /> + + + + + +
+ {displayedTab === SpanDetailTab.Overview && selectedSpanSource && ( )} - {displayedTab === Tab.Parsed && ( + {displayedTab === SpanDetailTab.Parsed && selectedSpanSource && ( )} - - )} - {traceSourceData != null && !eventRowWhere && traceId && ( - -
- Please select a span above to view details. -
-
+ {displayedTab === SpanDetailTab.Infrastructure && + hasSelectedSpanK8sContext && + selectedSpanSource && ( + + + + )} +
)}
); diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index f0d5aceafa..eed13f0ccc 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -13,12 +13,12 @@ import { TTraceSource, } from '@hyperdx/common-utils/dist/types'; import { + ActionIcon, Anchor, Box, Center, Checkbox, Code, - Divider, Group, Kbd, Text, @@ -26,16 +26,23 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { + IconAlertCircleFilled, + IconAlertTriangleFilled, IconChevronDown, IconChevronRight, + IconChevronsDown, + IconChevronsRight, IconLogs, + IconSparkles, + IconX, } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; -import SearchInputV2 from '@/components/SearchInput/SearchInputV2'; +import SearchWhereInput, { + getStoredLanguage, +} from '@/components/SearchInput/SearchWhereInput'; 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 { @@ -46,12 +53,11 @@ import { } from '@/source'; import { useFormatTime } from '@/useFormatTime'; import { + COLORS, getChartColorError, - getChartColorErrorHighlight, getChartColorSuccess, getChartColorSuccessHighlight, getChartColorWarning, - getChartColorWarningHighlight, } from '@/utils'; import { getHighlightedAttributesFromData, @@ -61,7 +67,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; @@ -83,28 +88,13 @@ export type SpanRow = { __hdx_hidden?: boolean | 1 | 0; }; -function textColor(condition: { isError: boolean; isWarn: boolean }): string { - const { isError, isWarn } = condition; - if (isError) return 'text-danger'; - if (isWarn) return 'text-warning'; - return ''; -} - function barColor(condition: { - isError: boolean; - isWarn: boolean; isHighlighted: boolean; + isError?: boolean; type: string | undefined; + serviceColor?: string; }) { - const { isError, isWarn, isHighlighted, type } = condition; - - if (isError) - return isHighlighted ? getChartColorErrorHighlight() : getChartColorError(); - - if (isWarn) - return isHighlighted - ? getChartColorWarningHighlight() - : getChartColorWarning(); + const { isHighlighted, isError, type, serviceColor } = condition; if (type === SourceKind.Log) { return isHighlighted @@ -112,6 +102,18 @@ function barColor(condition: { : getChartColorSuccess(); } + if (isError) { + return isHighlighted + ? `color-mix(in srgb, ${getChartColorError()} 60%, white)` + : getChartColorError(); + } + + if (serviceColor) { + return isHighlighted + ? `color-mix(in srgb, ${serviceColor} 60%, white)` + : serviceColor; + } + return isHighlighted ? '#A9AFB7' : '#6A7077'; } @@ -413,6 +415,204 @@ function CollapseTooltipLabel({ onShown }: { onShown: () => void }) { ); } +function fmtMs(ms: number): string { + if (ms < 1) return `${Math.round(ms * 1000)}µs`; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function generateTraceSummary(rows: any[], minOffset: number): string { + const spans = rows.filter( + (r: any) => r.type !== SourceKind.Log && r.Duration != null, + ); + if (!spans.length) return 'No span data available.'; + + const maxEnd = spans.reduce((acc: number, r: any) => { + const end = new Date(r.Timestamp).getTime() + (r.Duration || 0) * 1000; + return Math.max(acc, end); + }, 0); + const totalMs = maxEnd - minOffset; + + const rootSpan = spans.find( + (r: any) => !r.ParentSpanId || r.ParentSpanId === '', + ); + + const byService = new Map(); + for (const s of spans) { + const svc = s.ServiceName || 'unknown'; + const prev = byService.get(svc) || { count: 0, totalMs: 0 }; + prev.count += 1; + prev.totalMs += (s.Duration || 0) * 1000; + byService.set(svc, prev); + } + + const sorted = [...spans].sort( + (a: any, b: any) => (b.Duration || 0) - (a.Duration || 0), + ); + const slowest = sorted[0]; + const slowestMs = (slowest.Duration || 0) * 1000; + const slowestPct = totalMs > 0 ? Math.round((slowestMs / totalMs) * 100) : 0; + + const errorSpans = spans.filter( + (r: any) => + r.StatusCode === 'Error' || r.SeverityText?.toLowerCase() === 'error', + ); + + const lines: string[] = []; + + if (errorSpans.length > 0) { + const firstErr = errorSpans[0]; + const errSvc = firstErr.ServiceName || 'unknown'; + const errBody = firstErr.Body || 'unknown operation'; + + const errMessage = + firstErr.SpanEvents?.find( + (e: any) => e.Name === 'exception' || e.Name === 'error', + )?.Attributes?.['exception.message'] || ''; + + lines.push( + `⚠ ${errorSpans.length} error${errorSpans.length !== 1 ? 's' : ''} detected. ` + + `First error in **${errSvc}** → \`${errBody}\`` + + (errMessage ? `: "${errMessage}"` : '') + + '.', + ); + + if (errorSpans.length > 1) { + const errServices = [ + ...new Set(errorSpans.map((r: any) => r.ServiceName).filter(Boolean)), + ]; + lines.push( + `Errors span across ${errServices.length} service${errServices.length !== 1 ? 's' : ''}: ${errServices.join(', ')}.`, + ); + } + } + + if (slowestPct >= 30 && slowest !== rootSpan) { + lines.push( + `🐢 Bottleneck: \`${slowest.Body || 'unknown'}\` in **${slowest.ServiceName || 'unknown'}** ` + + `took ${fmtMs(slowestMs)} (${slowestPct}% of total trace). ` + + 'Consider optimizing this operation.', + ); + } + + const slowService = [...byService.entries()].sort( + (a, b) => b[1].totalMs - a[1].totalMs, + )[0]; + if (slowService && byService.size > 1) { + const ratio = totalMs > 0 ? slowService[1].totalMs / totalMs : 0; + const svcPct = Math.round(ratio * 100); + if (svcPct >= 40) { + lines.push( + `**${slowService[0]}** accounts for ~${svcPct}% of total time (${slowService[1].count} span${slowService[1].count !== 1 ? 's' : ''}).`, + ); + } + } + + if (errorSpans.length === 0 && lines.length === 0) { + lines.push( + `✓ Trace completed successfully in ${fmtMs(totalMs)}` + + (rootSpan ? ` for \`${rootSpan.Body}\`` : '') + + `. No errors or significant bottlenecks detected.`, + ); + } + + return lines.join(' '); +} + +function StreamingText({ text, speed = 12 }: { text: string; speed?: number }) { + const [charIndex, setCharIndex] = useState(0); + + useEffect(() => { + if (!text) return; + + const interval = setInterval(() => { + setCharIndex(prev => { + const next = prev + 1; + if (next >= text.length) { + clearInterval(interval); + return text.length; + } + return next; + }); + }, speed); + return () => clearInterval(interval); + }, [text, speed]); + + const displayed = text.slice(0, charIndex); + const isStreaming = charIndex < text.length; + + return ( + <> + {displayed} + {isStreaming && ( + + )} + + ); +} + +function TraceSummaryPanel({ + rows, + minOffset, + onClose, +}: { + rows: any[]; + minOffset: number; + onClose: () => void; +}) { + const summaryText = useMemo( + () => generateTraceSummary(rows, minOffset), + [rows, minOffset], + ); + + return ( + + + + + Trace Summary + + + + + + + + + + ); +} + // TODO: Optimize with ts lookup tables export function DBTraceWaterfallChartContainer({ traceTableSource, @@ -423,6 +623,7 @@ export function DBTraceWaterfallChartContainer({ onClick, highlightedRowWhere, initialRowHighlightHint, + headerExtra, }: { traceTableSource: TTraceSource; logTableSource: TLogSource | null; @@ -440,8 +641,8 @@ export function DBTraceWaterfallChartContainer({ spanId: string; body: string; }; + headerExtra?: React.ReactNode; }) { - const { size, startResize } = useResizable(30, 'bottom'); const formatTime = useFormatTime(); const { @@ -451,7 +652,7 @@ export function DBTraceWaterfallChartContainer({ isFilterActive, isFilterExpanded, setIsFilterExpanded, - onSubmit: onSubmitFilters, + onSubmit: submitFilters, } = useWaterfallSearchState({ hasLogSource: !!logTableSource, }); @@ -460,9 +661,20 @@ export function DBTraceWaterfallChartContainer({ defaultValues: { traceWhere: traceWhere ?? '', logWhere: logWhere ?? '', + traceWhereLanguage: getStoredLanguage() ?? 'lucene', }, }); + const onSubmitFilters = useCallback( + (data: { traceWhere: string; logWhere: string }) => { + submitFilters({ + traceWhere: data.traceWhere, + logWhere: data.traceWhere, + }); + }, + [submitFilters], + ); + const onClearFilters = useCallback(() => { setValue('traceWhere', ''); setValue('logWhere', ''); @@ -517,6 +729,29 @@ export function DBTraceWaterfallChartContainer({ } }); + const serviceColorMap = useMemo(() => { + const serviceNames = [ + ...new Set( + rows + .filter( + r => + r.ServiceName && + r.type !== SourceKind.Log && + typeof r.ServiceName === 'string', + ) + .map(r => r.ServiceName as string), + ), + ].sort(); + + // Skip COLORS[0] (green) — it's reserved for correlated log entries + const serviceColors = COLORS.slice(1); + const map = new Map(); + serviceNames.forEach((name, i) => { + map.set(name, serviceColors[i % serviceColors.length]); + }); + return map; + }, [rows]); + const highlightedAttributeValues = useMemo(() => { const visibleTraceRowsData = traceRowsData?.filter( row => !row.__hdx_hidden, @@ -598,8 +833,9 @@ export function DBTraceWaterfallChartContainer({ const [collapsedIds, setCollapsedIds] = useState>(new Set()); const [showSpanEvents, setShowSpanEvents] = useState(true); + const [showSummary, setShowSummary] = useState(false); - const { nodesMap, flattenedNodes } = useMemo(() => { + const { nodesMap, flattenedNodes, parentIdsByLevel } = useMemo(() => { const rootNodes: Node[] = []; const nodesMap = new Map(); // Maps result.id (or placeholder id) -> Node const spanIdMap = new Map(); // Maps SpanId -> result.id of FIRST node with that SpanId @@ -667,6 +903,19 @@ export function DBTraceWaterfallChartContainer({ } } + // Build a map of level → parent node IDs (nodes with children) + const parentIdsByLevel = new Map>(); + const collectParents = (node: Node, level: number) => { + if (node.children?.length > 0 && node.id) { + if (!parentIdsByLevel.has(level)) { + parentIdsByLevel.set(level, new Set()); + } + parentIdsByLevel.get(level)!.add(node.id); + } + node.children?.forEach((child: any) => collectParents(child, level + 1)); + }; + rootNodes.forEach(root => collectParents(root, 0)); + type NodeWithLevel = Node & { level: number }; // flatten the rootnode dag into an array via in-order traversal const traverse = (node: Node, arr: NodeWithLevel[], level = 0) => { @@ -690,7 +939,7 @@ export function DBTraceWaterfallChartContainer({ rootNodes.forEach(rootNode => traverse(rootNode, flattenedNodes)); } - return { nodesMap, flattenedNodes }; + return { nodesMap, flattenedNodes, parentIdsByLevel }; }, [collapsedIds, rows, validSpanIDs]); const toggleCollapse = useCallback( @@ -725,6 +974,55 @@ export function DBTraceWaterfallChartContainer({ [nodesMap], ); + const expandAll = useCallback(() => { + setCollapsedIds(new Set()); + }, []); + + const collapseAll = useCallback(() => { + const allParentIds = new Set(); + parentIdsByLevel.forEach(ids => { + ids.forEach(id => allParentIds.add(id)); + }); + setCollapsedIds(allParentIds); + }, [parentIdsByLevel]); + + const expandOneLevel = useCallback(() => { + setCollapsedIds(prev => { + if (prev.size === 0) return prev; + const newSet = new Set(prev); + // Find the shallowest collapsed level and expand those nodes + const sortedLevels = [...parentIdsByLevel.keys()].sort((a, b) => a - b); + for (const level of sortedLevels) { + const ids = parentIdsByLevel.get(level)!; + const collapsedAtLevel = [...ids].filter(id => newSet.has(id)); + if (collapsedAtLevel.length > 0) { + collapsedAtLevel.forEach(id => newSet.delete(id)); + break; + } + } + return newSet; + }); + }, [parentIdsByLevel]); + + const collapseOneLevel = useCallback(() => { + setCollapsedIds(prev => { + const newSet = new Set(prev); + // Find the deepest expanded level with parent nodes and collapse those + const sortedLevels = [...parentIdsByLevel.keys()].sort((a, b) => b - a); + for (const level of sortedLevels) { + const ids = parentIdsByLevel.get(level)!; + const expandedAtLevel = [...ids].filter(id => !newSet.has(id)); + if (expandedAtLevel.length > 0) { + expandedAtLevel.forEach(id => newSet.add(id)); + break; + } + } + return newSet; + }); + }, [parentIdsByLevel]); + + const hasCollapsibleNodes = parentIdsByLevel.size > 0; + const spanCount = flattenedNodes.length; const errorCount = flattenedNodes.filter( node => @@ -808,7 +1106,7 @@ export function DBTraceWaterfallChartContainer({ aliasWith, label: (
{collapsedIds.has(id) ? ( - + ) : ( - + )}{' '} - {!isFilterActive && ( - - {result.children.length > 0 - ? `(${result.children.length})` - : ''} +
+ + {result.children.length > 0 && ( + + ({result.children.length}) )} + {isError && ( + + )} + {isWarn && !isError && ( + + )} + {type === SourceKind.Log ? ( - {serviceName ? `${serviceName} | ` : ''} - {displayText} + {serviceName && <>{serviceName} } + + {displayText} +
@@ -912,10 +1244,12 @@ export function DBTraceWaterfallChartContainer({ tooltip: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${!isNaN(startOffset) ? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}` : ''}`, color: 'var(--color-text-inverted)', backgroundColor: barColor({ - isError, - isWarn, isHighlighted, + isError, type, + serviceColor: serviceName + ? serviceColorMap.get(serviceName) + : undefined, }), body: {displayText}, minWidthPerc: 1, @@ -931,9 +1265,9 @@ export function DBTraceWaterfallChartContainer({ flattenedNodes, formatTime, highlightedRowWhere, - isFilterActive, minOffset, onClick, + serviceColorMap, showSpanEvents, toggleCollapse, collapseTooltipShown, @@ -945,56 +1279,78 @@ export function DBTraceWaterfallChartContainer({ return v.id === highlightedRowWhere; }); - const heightPx = (size / 100) * window.innerHeight; - - return ( + const controlsHeader = ( <> {isFilterExpanded && ( - - Spans filter - + + setValue('traceWhereLanguage', lang, { + shouldDirty: true, + }) + } + lucenePlaceholder='Filter spans & logs ex. StatusCode:"Error"' + sqlPlaceholder="Filter spans & logs ex. StatusCode = 'Error'" data-testid="trace-search-input" /> - - {logTableSource && ( - <> - Logs filter - - - )} )} + {hasCollapsibleNodes && ( + + + + + + + + + + + + + + + + + + + + + + + )} {spanCountString},{' '} @@ -1008,62 +1364,91 @@ export function DBTraceWaterfallChartContainer({ onChange={() => setShowSpanEvents(!showSpanEvents)} /> - - setIsFilterExpanded(prev => !prev)} - size="xs" - > - {isFilterExpanded ? 'Hide Filters' : 'Show Filters'}{' '} - {isFilterActive && '(active)'} - - {isFilterActive && ( + + + setShowSummary(prev => !prev)} + aria-label="Summarize trace" + > + + + + {headerExtra} + setIsFilterExpanded(prev => !prev)} size="xs" - ms="xs" > - Clear Filters + {isFilterExpanded ? 'Hide Filters' : 'Show Filters'}{' '} + {isFilterActive && '(active)'} - )} - + {isFilterActive && ( + + Clear Filters + + )} + + {!isFetching && !error && highlightedAttributeValues?.length > 0 && ( )} -
- {isFetching ? ( -
Loading Traces...
- ) : error ? ( - - - An error occurred while fetching trace data: - - - {error.message} - - - ) : rows == null ? ( -
- An unknown error occurred. -
- ) : flattenedNodes.length === 0 ? ( -
No matching spans or logs found
- ) : ( + + ); + + return ( +
+ {isFetching ? ( +
Loading Traces...
+ ) : error ? ( + + + An error occurred while fetching trace data: + + + {error.message} + + + ) : rows == null ? ( +
+ An unknown error occurred. +
+ ) : flattenedNodes.length === 0 ? ( +
No matching spans or logs found
+ ) : ( +
( + <> + {minimap} + {controlsHeader} + {showSummary && rows && ( + setShowSummary(false)} + /> + )} + + )} /> - )} -
- - +
+ )} +
); } diff --git a/packages/app/src/components/DrawerUtils.tsx b/packages/app/src/components/DrawerUtils.tsx index ef524d4af4..5b46fd6a49 100644 --- a/packages/app/src/components/DrawerUtils.tsx +++ b/packages/app/src/components/DrawerUtils.tsx @@ -2,6 +2,17 @@ import * as React from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Box, CloseButton, Group, Text } from '@mantine/core'; +const LARGE_SCREEN_BREAKPOINT = 1440; +const LARGE_SCREEN_WIDTH_PX = 1100; +const SMALL_SCREEN_WIDTH_PERCENT = 85; + +export function getInitialDrawerWidthPercent(): number { + if (typeof window === 'undefined') return SMALL_SCREEN_WIDTH_PERCENT; + return window.innerWidth > LARGE_SCREEN_BREAKPOINT + ? (LARGE_SCREEN_WIDTH_PX / window.innerWidth) * 100 + : SMALL_SCREEN_WIDTH_PERCENT; +} + export const DrawerHeader = React.memo<{ header?: React.ReactNode; onClose?: () => void; diff --git a/packages/app/src/components/HyperJson.module.scss b/packages/app/src/components/HyperJson.module.scss index 5ae51e23eb..7fe3d8609a 100644 --- a/packages/app/src/components/HyperJson.module.scss +++ b/packages/app/src/components/HyperJson.module.scss @@ -33,10 +33,9 @@ align-items: flex-start; border-radius: 3px; position: relative; - overflow: hidden; &:hover { - background-color: var(--color-bg-highlighted); + background-color: var(--color-bg-muted); .lineMenu { display: flex; @@ -71,30 +70,11 @@ .lineMenu { display: none; align-items: center; + gap: 4px; position: absolute; - right: 0; - top: 0; - height: calc(100% + 1px); - max-height: 24px; - border-bottom: 1px solid var(--color-border); -} - -.lineMenuBtn { - border: 0; - background-color: rgb(0 0 0 / 20%); - backdrop-filter: blur(4px); - color: var(--color-text); - border-left: 1px solid var(--color-border); - padding: 0 8px; - height: 100%; - - &:hover { - background-color: var(--color-bg-highlighted); - } - - &:active { - background-color: var(--color-bg-muted); - } + right: 4px; + top: 50%; + transform: translateY(-50%); } .clickable { diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx index 160a65bff4..0e1f1cfa9a 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -17,6 +17,8 @@ import { IconClipboard, } from '@tabler/icons-react'; +import { DBRowTableIconButton } from '@/components/DBTable/DBRowTableIconButton'; + import styles from './HyperJson.module.scss'; export type LineAction = { @@ -131,17 +133,15 @@ const LineMenu = React.memo( return (
{lineActions.map(action => ( - + ))}
); diff --git a/packages/app/src/components/PatternSidePanel.tsx b/packages/app/src/components/PatternSidePanel.tsx index d8754ce31c..3cb89803b5 100644 --- a/packages/app/src/components/PatternSidePanel.tsx +++ b/packages/app/src/components/PatternSidePanel.tsx @@ -176,8 +176,6 @@ export default function PatternSidePanel({ rowId={selectedRowWhere.where} aliasWith={selectedRowWhere.aliasWith} onClose={handleCloseRowSidePanel} - isNestedPanel={true} - breadcrumbPath={[{ label: 'Pattern Overview' }]} /> )}
diff --git a/packages/app/src/components/SQLEditor/SQLEditor.tsx b/packages/app/src/components/SQLEditor/SQLEditor.tsx index ba14096106..d445a0e442 100644 --- a/packages/app/src/components/SQLEditor/SQLEditor.tsx +++ b/packages/app/src/components/SQLEditor/SQLEditor.tsx @@ -98,7 +98,7 @@ export default function SQLEditor({ minHeight={'100px'} extensions={[ createCodeMirrorStyleTheme(), - // eslint-disable-next-line react-hooks/refs + compartmentRef.current.of( clickhouseSql({ upperCaseKeywords: true, diff --git a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx index 9e8dfaff1e..94224486bd 100644 --- a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx @@ -242,7 +242,6 @@ export default function SQLInlineEditor({ // Enable line wrapping when multiline is allowed (regardless of focus) ...(allowMultiline ? [EditorView.lineWrapping] : []), - // eslint-disable-next-line react-hooks/refs compartmentRef.current.of( clickhouseSql({ upperCaseKeywords: true, diff --git a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx index 82be32f4a5..933af4782c 100644 --- a/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx +++ b/packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx @@ -112,8 +112,6 @@ export default function SlowestEventsTile({ expressions && ( <> void; +}; + +function SourceIcon({ kind }: { kind?: SourceKind }) { + if (kind === SourceKind.Trace) { + return ; + } + if (kind === SourceKind.Log) { + return ; + } + if (kind === SourceKind.Session) { + return ; + } + return null; +} + +function truncate(text: string, max: number) { + if (text.length <= max) return text; + return `${text.slice(0, max)}…`; +} + +function SidePanelBreadcrumbs({ + items, + onBack, +}: { + items: BreadcrumbItem[]; + /** Always shown before the trail; closes the panel at root or pops navigation. */ + onBack: () => void; +}) { + const breadcrumbElements = useMemo(() => { + return items.map((item, i) => { + const isLast = i === items.length - 1; + const isSingle = items.length === 1; + const maxLen = isSingle + ? MAX_LABEL_LENGTH_SINGLE + : isLast + ? MAX_LABEL_LENGTH_CURRENT + : MAX_LABEL_LENGTH_PREVIOUS; + const truncatedLabel = truncate(item.label, maxLen); + const needsTooltip = item.label.length > maxLen; + + const content = ( + + {i === 0 && } + + {truncatedLabel} + + + ); + + const wrapped = needsTooltip ? ( + + {content} + + ) : ( + content + ); + + if (isLast || !item.onClick) { + return ( + + {wrapped} + + ); + } + + return ( + + + {wrapped} + + + ); + }); + }, [items]); + + return ( + + + + + + + {items.length > 0 ? ( + + {breadcrumbElements} + + ) : null} + + ); +} + +export default memo(SidePanelBreadcrumbs); diff --git a/packages/app/src/components/TimelineChart/TimelineChart.module.scss b/packages/app/src/components/TimelineChart/TimelineChart.module.scss index 79d86d4be4..1d3174aeef 100644 --- a/packages/app/src/components/TimelineChart/TimelineChart.module.scss +++ b/packages/app/src/components/TimelineChart/TimelineChart.module.scss @@ -18,3 +18,23 @@ padding-right: 8px; position: relative; } + +.barDetail { + position: absolute; + top: 0; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + pointer-events: none; +} + +.barDetailBody { + display: none; + color: var(--color-text-muted); +} + +.timelineRow:hover .barDetailBody, +.timelineRowActive .barDetailBody { + display: inline; +} diff --git a/packages/app/src/components/TimelineChart/TimelineChart.tsx b/packages/app/src/components/TimelineChart/TimelineChart.tsx index 814278f966..5e7aaa403d 100644 --- a/packages/app/src/components/TimelineChart/TimelineChart.tsx +++ b/packages/app/src/components/TimelineChart/TimelineChart.tsx @@ -7,7 +7,6 @@ import { useState, } from 'react'; import cx from 'classnames'; -import { Flex, Kbd, Text } from '@mantine/core'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useDrag } from '@/hooks/useDrag'; @@ -21,6 +20,7 @@ import { type TTimelineEvent, } from './TimelineChartRowEvents'; import { TimelineCursor } from './TimelineCursor'; +import { TimelineMinimap } from './TimelineMinimap'; import { TimelineMouseCursor } from './TimelineMouseCursor'; import { TimelineXAxis } from './TimelineXAxis'; @@ -49,9 +49,8 @@ type TimelineChartProps = { rowHeight: number; onEventClick?: (e: Row) => void; labelWidth: number; - className?: string; - maxHeight: number; initialScrollRowIndex?: number; + renderHeader?: (minimap: React.ReactNode) => React.ReactNode; }; export const TimelineChart = memo(function ({ @@ -60,9 +59,8 @@ export const TimelineChart = memo(function ({ rowHeight, onEventClick, labelWidth: initialLabelWidth, - className, - maxHeight, initialScrollRowIndex, + renderHeader, }: TimelineChartProps) { const [scale, setScale] = useState(1); const [offset, setOffset] = useState(0); @@ -189,20 +187,20 @@ export const TimelineChart = memo(function ({ } }, [initialScrollRowIndex, initialScrolled, rowVirtualizer]); + const minimapElement = ( + + ); + return ( -
- - - ⌘/Ctrl + scroll to zoom - - + <> + {renderHeader ? renderHeader(minimapElement) : minimapElement}
-
+ ); }); diff --git a/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx b/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx index 55f541e9f2..3481c0dc38 100644 --- a/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx +++ b/packages/app/src/components/TimelineChart/TimelineChartRowEvents.tsx @@ -7,6 +7,8 @@ import { } from './TimelineSpanEventMarker'; import { renderMs } from './utils'; +import styles from './TimelineChart.module.scss'; + export type TTimelineEvent = { id: string; start: number; @@ -62,8 +64,7 @@ export const TimelineChartRowEvents = memo(function ({ const durationMs = e.end - e.start; const barCenter = (e.start + e.end) / 2; const timelineMidpoint = maxVal / 2; - // Duration on left when majority of bar is past halfway, otherwise on right - const durationOnRight = barCenter <= timelineMidpoint; + const onRight = barCenter <= timelineMidpoint; return (
onEventHover?.(e.id)} - className="d-flex align-items-center h-100 cursor-pointer text-truncate hover-opacity" + className="d-flex align-items-center h-100 cursor-pointer hover-opacity" style={{ userSelect: 'none', width: '100%', position: 'relative', borderRadius: 2, fontSize: height * 0.5, - color: e.color, backgroundColor: e.backgroundColor, }} > -
- {e.body} -
{e.markers?.map((marker, idx) => ( {!!e.showDuration && ( - {renderMs(durationMs)} + + {renderMs(durationMs)} + + {e.body} )}
diff --git a/packages/app/src/components/TimelineChart/TimelineMinimap.module.scss b/packages/app/src/components/TimelineChart/TimelineMinimap.module.scss new file mode 100644 index 0000000000..a0e85f7041 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineMinimap.module.scss @@ -0,0 +1,83 @@ +.container { + position: relative; + border: 1px solid var(--color-border); + background-color: var(--color-bg-surface); + overflow: hidden; + user-select: none; +} + +.tick { + position: absolute; + top: 0; + border-left: 1px solid var(--color-border-muted); + pointer-events: none; +} + +.tickLabel { + font-size: 10px; + color: var(--color-text-muted); + margin-left: 3px; + white-space: nowrap; + user-select: none; +} + +.bar { + position: absolute; + border-radius: 1px; + pointer-events: none; +} + +.dimmedOverlay { + position: absolute; + top: 0; + height: 100%; + background-color: var(--color-viz-range-overlay); + pointer-events: none; +} + +.viewportFrame { + position: absolute; + top: 0; + height: 100%; + box-sizing: border-box; + border: 1px solid var(--color-viz-range-border); + pointer-events: none; +} + +.handleGrip { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 20px; + background-color: var(--color-viz-range-handle); + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + gap: 1px; + + &Left { + left: -3px; + } + + &Right { + right: -3px; + } +} + +.handleGripLine { + width: 0; + height: 8px; + border-left: 1px solid var(--color-viz-range-handle-grip); +} + +.brushOverlay { + position: absolute; + top: 0; + height: 100%; + background-color: var(--color-viz-range-brush); + border: 1px solid var(--color-viz-range-brush-border); + box-sizing: border-box; + pointer-events: none; +} diff --git a/packages/app/src/components/TimelineChart/TimelineMinimap.tsx b/packages/app/src/components/TimelineChart/TimelineMinimap.tsx new file mode 100644 index 0000000000..27d0863c43 --- /dev/null +++ b/packages/app/src/components/TimelineChart/TimelineMinimap.tsx @@ -0,0 +1,329 @@ +import { memo, useCallback, useMemo, useRef, useState } from 'react'; + +import type { TTimelineEvent } from './TimelineChartRowEvents'; +import { renderMs } from './utils'; + +import styles from './TimelineMinimap.module.scss'; + +type MinimapRow = { + events: TTimelineEvent[]; +}; + +type DragMode = 'pan' | 'resize-left' | 'resize-right' | 'brush' | null; + +type TimelineMinimapProps = { + rows: MinimapRow[]; + maxVal: number; + scale: number; + offset: number; + setOffset: (fn: (v: number) => number) => void; + setScale: (fn: (v: number) => number) => void; +}; + +const TICK_HEIGHT = 18; +const BAR_AREA_HEIGHT = 34; +const MINIMAP_HEIGHT = TICK_HEIGHT + BAR_AREA_HEIGHT; +const HANDLE_HIT_AREA = 8; +const BAR_HEIGHT = 2; + +function getXFraction(clientX: number, containerEl: HTMLDivElement): number { + const rect = containerEl.getBoundingClientRect(); + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); +} + +const HandleGrip = ({ side }: { side: 'left' | 'right' }) => ( +
+
+
+
+); + +export const TimelineMinimap = memo(function ({ + rows, + maxVal, + scale, + offset, + setOffset, + setScale, +}: TimelineMinimapProps) { + const containerRef = useRef(null); + const dragMode = useRef(null); + const dragStartX = useRef(0); + const dragStartOffset = useRef(0); + const dragStartScale = useRef(1); + const brushStartFrac = useRef(0); + const brushRangeRef = useRef<{ start: number; end: number } | null>(null); + + const [brushRange, setBrushRange] = useState<{ + start: number; + end: number; + } | null>(null); + + const viewportStartFrac = offset / 100; + const viewportWidthFrac = 1 / scale; + const viewportEndFrac = viewportStartFrac + viewportWidthFrac; + const isZoomed = scale > 1.01; + + const ticks = useMemo(() => { + const TARGET_TICKS = 6; + const rawInterval = maxVal / TARGET_TICKS; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval))); + let interval = magnitude; + if (rawInterval >= 2 * magnitude) interval = 2 * magnitude; + if (rawInterval >= 5 * magnitude) interval = 5 * magnitude; + + const result = []; + for (let i = 0; ; i++) { + const val = i * interval; + const frac = val / maxVal; + if (frac > 1) break; + result.push({ frac, label: renderMs(val) }); + } + return result; + }, [maxVal]); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + const el = containerRef.current; + if (!el) return; + + e.preventDefault(); + e.stopPropagation(); + el.setPointerCapture(e.pointerId); + + const x = getXFraction(e.clientX, el); + const handleFrac = HANDLE_HIT_AREA / el.getBoundingClientRect().width; + + if (isZoomed && Math.abs(x - viewportStartFrac) < handleFrac) { + dragMode.current = 'resize-left'; + } else if (isZoomed && Math.abs(x - viewportEndFrac) < handleFrac) { + dragMode.current = 'resize-right'; + } else if (x >= viewportStartFrac && x <= viewportEndFrac) { + if (isZoomed) { + dragMode.current = 'pan'; + } else { + dragMode.current = 'brush'; + brushStartFrac.current = x; + const range = { start: x, end: x }; + brushRangeRef.current = range; + setBrushRange(range); + } + } else { + dragMode.current = 'brush'; + brushStartFrac.current = x; + const range = { start: x, end: x }; + brushRangeRef.current = range; + setBrushRange(range); + } + + dragStartX.current = e.clientX; + dragStartOffset.current = offset; + dragStartScale.current = scale; + }, + [viewportStartFrac, viewportEndFrac, offset, scale, isZoomed], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragMode.current) return; + const el = containerRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + + if (dragMode.current === 'brush') { + const x = getXFraction(e.clientX, el); + const start = Math.min(brushStartFrac.current, x); + const end = Math.max(brushStartFrac.current, x); + const range = { start, end }; + brushRangeRef.current = range; + setBrushRange(range); + return; + } + + const deltaFrac = (e.clientX - dragStartX.current) / rect.width; + + if (dragMode.current === 'pan') { + const newOffset = Math.min( + Math.max(dragStartOffset.current + deltaFrac * 100, 0), + 100 - 100 / dragStartScale.current, + ); + setOffset(() => newOffset); + } else if (dragMode.current === 'resize-left') { + const currentStartFrac = dragStartOffset.current / 100; + const currentEndFrac = currentStartFrac + 1 / dragStartScale.current; + const newStartFrac = Math.max( + 0, + Math.min(currentStartFrac + deltaFrac, currentEndFrac - 0.02), + ); + const newWidthFrac = currentEndFrac - newStartFrac; + const newScale = 1 / newWidthFrac; + setScale(() => Math.max(newScale, 1)); + setOffset(() => + Math.min(Math.max(newStartFrac * 100, 0), 100 - 100 / newScale), + ); + } else if (dragMode.current === 'resize-right') { + const currentStartFrac = dragStartOffset.current / 100; + const currentEndFrac = currentStartFrac + 1 / dragStartScale.current; + const newEndFrac = Math.min( + 1, + Math.max(currentEndFrac + deltaFrac, currentStartFrac + 0.02), + ); + const newWidthFrac = newEndFrac - currentStartFrac; + const newScale = 1 / newWidthFrac; + setScale(() => Math.max(newScale, 1)); + setOffset(() => + Math.min(Math.max(currentStartFrac * 100, 0), 100 - 100 / newScale), + ); + } + }, + [setOffset, setScale], + ); + + const handlePointerUp = useCallback(() => { + if (dragMode.current === 'brush') { + const range = brushRangeRef.current; + if (range) { + const width = range.end - range.start; + if (width > 0.01) { + const newScale = 1 / width; + setScale(() => Math.max(newScale, 1)); + setOffset(() => + Math.min( + Math.max(range.start * 100, 0), + 100 - 100 / Math.max(newScale, 1), + ), + ); + } + } + } + dragMode.current = null; + brushRangeRef.current = null; + setBrushRange(null); + }, [setScale, setOffset]); + + const handleDoubleClick = useCallback(() => { + if (isZoomed) { + setScale(() => 1); + setOffset(() => 0); + } + }, [isZoomed, setScale, setOffset]); + + const getCursor = useCallback( + (e: React.PointerEvent) => { + if (!isZoomed) return 'crosshair'; + const el = containerRef.current; + if (!el) return 'crosshair'; + const x = getXFraction(e.clientX, el); + const handleFrac = HANDLE_HIT_AREA / el.getBoundingClientRect().width; + + if (Math.abs(x - viewportStartFrac) < handleFrac) return 'col-resize'; + if (Math.abs(x - viewportEndFrac) < handleFrac) return 'col-resize'; + if (x >= viewportStartFrac && x <= viewportEndFrac) return 'grab'; + return 'crosshair'; + }, + [viewportStartFrac, viewportEndFrac, isZoomed], + ); + + return ( +
{ + handlePointerMove(e); + if (!dragMode.current) { + e.currentTarget.style.cursor = getCursor(e); + } else if (dragMode.current === 'pan') { + e.currentTarget.style.cursor = 'grabbing'; + } else if (dragMode.current === 'brush') { + e.currentTarget.style.cursor = 'crosshair'; + } else { + e.currentTarget.style.cursor = 'col-resize'; + } + }} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + > + {ticks.map(({ frac, label }) => ( +
+ {label} +
+ ))} + + {rows.map((row, rowIdx) => + row.events.map(event => { + const left = (event.start / maxVal) * 100; + const width = Math.max( + ((event.end - event.start) / maxVal) * 100, + 0.15, + ); + return ( +
+ ); + }), + )} + + {isZoomed && ( + <> +
+
+
+ + +
+ + )} + + {brushRange && ( +
+ )} +
+ ); +}); + +TimelineMinimap.displayName = 'TimelineMinimap'; diff --git a/packages/app/src/components/TimelineChart/TimelineXAxis.tsx b/packages/app/src/components/TimelineChart/TimelineXAxis.tsx index cd7df93d01..e725a62f34 100644 --- a/packages/app/src/components/TimelineChart/TimelineXAxis.tsx +++ b/packages/app/src/components/TimelineChart/TimelineXAxis.tsx @@ -14,51 +14,88 @@ export function TimelineXAxis({ offset: number; }) { const scaledMaxVal = maxVal / scale; - // TODO: Turn this into a function const interval = calculateInterval(scaledMaxVal); const numTicks = Math.floor(maxVal / interval); const percSpacing = (interval / maxVal) * 100 * scale; - const ticks = []; + const labels = []; + const gridLines = []; for (let i = 0; i < numTicks; i++) { - ticks.push( + const ml = i === 0 ? 0 : `${percSpacing.toFixed(6)}%`; + labels.push(
{renderMs(i * interval)}
, ); + gridLines.push( +
, + ); } + const offsetMargin = `${(-1 * offset * scale).toFixed(6)}%`; + return ( -
-
-
-
-
- {ticks} + <> + {/* Grid lines — behind rows */} +
+
+
+
+
+ {gridLines} +
+
+
+ + {/* Sticky header with labels — above rows */} +
+
+
+
+
+ {labels} +
-
+ ); } diff --git a/packages/app/src/components/TimelineChart/utils.ts b/packages/app/src/components/TimelineChart/utils.ts index dc1fe17570..e352a6e30f 100644 --- a/packages/app/src/components/TimelineChart/utils.ts +++ b/packages/app/src/components/TimelineChart/utils.ts @@ -18,21 +18,17 @@ 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; - - // 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))); - - // Adjust the interval to the nearest standard bucket size - let bucketSize = magnitude; - if (interval >= 2 * magnitude) { - bucketSize = 2 * magnitude; - } - if (interval >= 5 * magnitude) { - bucketSize = 5 * magnitude; +export function calculateInterval(value: number, maxTicks = 15) { + const rough = value / maxTicks; + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); + + const standards = [1, 2, 5, 10]; + for (const s of standards) { + const candidate = s * magnitude; + if (value / candidate <= maxTicks) { + return candidate; + } } - return bucketSize; + return 10 * magnitude; } diff --git a/packages/app/src/dashboardTemplates/dotnet-runtime.json b/packages/app/src/dashboardTemplates/dotnet-runtime.json index 2cb5afcff5..43cd4fa9f9 100644 --- a/packages/app/src/dashboardTemplates/dotnet-runtime.json +++ b/packages/app/src/dashboardTemplates/dotnet-runtime.json @@ -2,9 +2,7 @@ "version": "0.1.0", "name": ".NET Runtime Metrics", "description": "Garbage collection, heap fragmentation, exception, thread pool, and CPU metrics for .NET v9+ applications with the OpenTelemetry.Instrumentation.Runtime package", - "tags": [ - "OTel Runtime Metrics" - ], + "tags": ["OTel Runtime Metrics"], "tiles": [ { "id": "6d4b7e", diff --git a/packages/app/src/dashboardTemplates/go-runtime.json b/packages/app/src/dashboardTemplates/go-runtime.json index 9c1fa93450..d979270feb 100644 --- a/packages/app/src/dashboardTemplates/go-runtime.json +++ b/packages/app/src/dashboardTemplates/go-runtime.json @@ -2,9 +2,7 @@ "version": "0.1.0", "name": "Go Runtime Metrics", "description": "Memory usage, allocations, GC targets, CPU utilization, and goroutine metrics for Go applications with the OTel Runtime (v0.62+) and Host Metrics instrumentations", - "tags": [ - "OTel Runtime Metrics" - ], + "tags": ["OTel Runtime Metrics"], "tiles": [ { "id": "109853", diff --git a/packages/app/src/dashboardTemplates/jvm-runtime-metrics.json b/packages/app/src/dashboardTemplates/jvm-runtime-metrics.json index 02cf77ec8f..e3bf40ee9e 100644 --- a/packages/app/src/dashboardTemplates/jvm-runtime-metrics.json +++ b/packages/app/src/dashboardTemplates/jvm-runtime-metrics.json @@ -2,9 +2,7 @@ "version": "0.1.0", "name": "JVM Runtime Metrics", "description": "Heap memory, CPU utilization, threads, and GC metrics for JVM applications with the OTel Java Agent v2+ wth JVM v17+", - "tags": [ - "OTel Runtime Metrics" - ], + "tags": ["OTel Runtime Metrics"], "tiles": [ { "id": "dd919b", diff --git a/packages/app/src/dashboardTemplates/nodejs-runtime.json b/packages/app/src/dashboardTemplates/nodejs-runtime.json index 1fc2837df1..2f9130e80f 100644 --- a/packages/app/src/dashboardTemplates/nodejs-runtime.json +++ b/packages/app/src/dashboardTemplates/nodejs-runtime.json @@ -2,9 +2,7 @@ "version": "0.1.0", "name": "Node.js Runtime Metrics", "description": "Event loop delay, heap usage, CPU utilization, and V8 memory for Node.js applications with OTel Runtime and Host Metrics instrumentations", - "tags": [ - "OTel Runtime Metrics" - ], + "tags": ["OTel Runtime Metrics"], "tiles": [ { "id": "55ef66", diff --git a/packages/app/src/hooks/useResizable.tsx b/packages/app/src/hooks/useResizable.tsx index fcc4ac51ce..e57a9c409c 100644 --- a/packages/app/src/hooks/useResizable.tsx +++ b/packages/app/src/hooks/useResizable.tsx @@ -52,7 +52,7 @@ function useResizable( const endResize = useCallback(() => { document.removeEventListener('mousemove', handleResize); - // eslint-disable-next-line react-hooks/immutability + document.removeEventListener('mouseup', endResize); }, [handleResize]); @@ -76,6 +76,7 @@ function useResizable( return { size: sizePercentage, + setSize: setSizePercentage, startResize, }; } diff --git a/packages/app/src/hooks/useStableCallback.ts b/packages/app/src/hooks/useStableCallback.ts index 2ca50d1bf0..fc978faddf 100644 --- a/packages/app/src/hooks/useStableCallback.ts +++ b/packages/app/src/hooks/useStableCallback.ts @@ -10,7 +10,7 @@ export const useStableCallback = any>( }); return useCallback( - // eslint-disable-next-line react-hooks/use-memo, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ((...args: Parameters) => callbackRef.current(...args)) as T, [], ); diff --git a/packages/app/src/theme/themes/clickstack/_tokens.scss b/packages/app/src/theme/themes/clickstack/_tokens.scss index 24d7986181..543d01daed 100644 --- a/packages/app/src/theme/themes/clickstack/_tokens.scss +++ b/packages/app/src/theme/themes/clickstack/_tokens.scss @@ -200,6 +200,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--palette-brand-300); + --color-viz-range-handle-grip: var(--color-text-inverted); + --color-viz-range-border: var(--palette-brand-700); + --color-viz-range-overlay: rgb(0 0 0 / 20%); + --color-viz-range-brush: rgb(250 255 105 / 20%); + --color-viz-range-brush-border: rgb(250 255 105 / 50%); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 25%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body) !important; --mantine-color-text: var(--color-text) !important; @@ -401,6 +412,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--mantine-color-indigo-8); + --color-viz-range-handle-grip: var(--mantine-color-indigo-1); + --color-viz-range-border: var(--mantine-color-indigo-8); + --color-viz-range-overlay: rgb(0 0 0 / 4%); + --color-viz-range-brush: rgb(67 126 239 / 20%); + --color-viz-range-brush-border: var(--mantine-color-indigo-8); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 4%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body) !important; --mantine-color-text: var(--color-text) !important; diff --git a/packages/app/src/theme/themes/hyperdx/_tokens.scss b/packages/app/src/theme/themes/hyperdx/_tokens.scss index 275e1b3e90..82dc1cb66d 100644 --- a/packages/app/src/theme/themes/hyperdx/_tokens.scss +++ b/packages/app/src/theme/themes/hyperdx/_tokens.scss @@ -116,6 +116,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--mantine-color-dark-2); + --color-viz-range-handle-grip: rgb(255 255 255 / 45%); + --color-viz-range-border: var(--mantine-color-dark-3); + --color-viz-range-overlay: rgb(0 0 0 / 20%); + --color-viz-range-brush: rgb(100 149 237 / 25%); + --color-viz-range-brush-border: rgb(100 149 237 / 60%); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 25%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body) !important; --mantine-color-text: var(--color-text); @@ -228,6 +239,17 @@ --color-chart-warning-highlight: #f5c94d; --color-chart-error-highlight: #ffa090; + /* Visualization Range Selector (minimap viewports, brush ranges, etc.) */ + --color-viz-range-handle: var(--mantine-color-gray-5); + --color-viz-range-handle-grip: rgb(255 255 255 / 80%); + --color-viz-range-border: var(--mantine-color-gray-4); + --color-viz-range-overlay: rgb(0 0 0 / 10%); + --color-viz-range-brush: rgb(66 105 208 / 20%); + --color-viz-range-brush-border: rgb(66 105 208 / 50%); + + /* Shadows */ + --shadow-drawer: -4px 0 24px rgb(0 0 0 / 4%); + /* Mantine Overrides */ --mantine-color-body: var(--color-bg-body); --mantine-color-text: var(--color-text); diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 175fa35159..f8f24f3d68 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -243,13 +243,12 @@ export function useTimeQuery({ liveTailTimeRange == null && tempLiveTailTimeRange == null && !isInputTimeQueryLive(inputTimeQuery) && - // eslint-disable-next-line react-hooks/refs inputTimeQueryDerivedTimeQueryRef.current != null ) { // Use the input time query, allows users to specify relative time ranges // via url ex. /logs?tq=Last+30+minutes // return inputTimeQueryDerivedTimeQuery as [Date, Date]; - // eslint-disable-next-line react-hooks/refs + return inputTimeQueryDerivedTimeQueryRef.current; } else if ( isReady && @@ -345,7 +344,6 @@ export function useTimeQuery({ ], ); - // eslint-disable-next-line react-hooks/refs return { isReady, // Don't search until we know what we want to do isLive, diff --git a/packages/app/src/useQueryParam.tsx b/packages/app/src/useQueryParam.tsx index 5684086598..0f4f3ef77b 100644 --- a/packages/app/src/useQueryParam.tsx +++ b/packages/app/src/useQueryParam.tsx @@ -28,7 +28,6 @@ export const QueryParamProvider = ({ const setState = useCallback( (state: Record) => { - // eslint-disable-next-line react-hooks/immutability setCache(oldCache => { const newCache = { ...oldCache, diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index e20b5b1d91..1aa0b57250 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -643,7 +643,7 @@ export const usePrevious = (value: T): T | undefined => { useEffect(() => { ref.current = value; }); - // eslint-disable-next-line react-hooks/refs + return ref.current; }; diff --git a/packages/app/styles/LogSidePanel.module.scss b/packages/app/styles/LogSidePanel.module.scss index e899b6b223..e1fefe3744 100644 --- a/packages/app/styles/LogSidePanel.module.scss +++ b/packages/app/styles/LogSidePanel.module.scss @@ -4,7 +4,6 @@ font-size: 12px; height: 100%; background: var(--color-bg-body); - box-shadow: 0 0 100px 40px rgb(0 0 0 / 70%); border-left: 1px solid var(--color-border); } @@ -70,7 +69,7 @@ .kbdShortcuts { background: var(--color-bg-muted); border-top: 1px solid var(--color-border); - padding: 12px var(--mantine-spacing-xl); + padding: var(--mantine-spacing-sm); color: var(--color-text-secondary); font-size: 11px; } diff --git a/packages/app/styles/ResizablePanel.module.scss b/packages/app/styles/ResizablePanel.module.scss index f6b2e5e447..5801f0bb1a 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; diff --git a/packages/app/styles/globals.css b/packages/app/styles/globals.css index c588c07f59..a1c581d163 100644 --- a/packages/app/styles/globals.css +++ b/packages/app/styles/globals.css @@ -42,3 +42,9 @@ a { display: grid; grid-template-rows: auto 1fr; } + +@keyframes blink { + 50% { + opacity: 0; + } +} diff --git a/packages/app/tests/e2e/features/search/search.spec.ts b/packages/app/tests/e2e/features/search/search.spec.ts index c9dda8cb1c..3cfe50ef09 100644 --- a/packages/app/tests/e2e/features/search/search.spec.ts +++ b/packages/app/tests/e2e/features/search/search.spec.ts @@ -53,7 +53,7 @@ test.describe('Search', { tag: '@search' }, () => { }); await test.step('Navigate through all side panel tabs', async () => { - const tabs = ['parsed', 'trace', 'context', 'overview']; + const tabs = ['parsed', 'context', 'overview']; // Use side panel component to navigate tabs for (const tabName of tabs) {