feat: Add click + sidepanel support to items within surrounding context (#989)

Fixes HDX-1951
This commit is contained in:
Tom Alexander 2025-07-30 13:27:13 -04:00 committed by GitHub
parent 229b511f64
commit 86115fa58a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 323 additions and 82 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add click + sidepanel support to items within surrounding context

View file

@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { sq } from 'date-fns/locale';
import ms from 'ms';
import { parseAsString, useQueryState } from 'nuqs';
import { useForm } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/metadata';
import {
@ -13,8 +14,14 @@ import { useDebouncedValue } from '@mantine/hooks';
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import WhereLanguageControlled from '@/components/WhereLanguageControlled';
import SearchInputV2 from '@/SearchInputV2';
import { useSource } from '@/source';
import { formatAttributeClause } from '@/utils';
import DBRowSidePanel from './DBRowSidePanel';
import {
BreadcrumbNavigationCallback,
BreadcrumbPath,
} from './DBRowSidePanelHeader';
import { DBSqlRowTable } from './DBRowTable';
enum ContextBy {
@ -31,6 +38,33 @@ interface ContextSubpanelProps {
dbSqlRowTableConfig: ChartConfigWithDateRange | undefined;
rowData: Record<string, any>;
rowId: string | undefined;
breadcrumbPath?: BreadcrumbPath;
onBreadcrumbClick?: BreadcrumbNavigationCallback;
}
// Custom hook to manage nested panel state
function useNestedPanelState(isNested: boolean) {
// Query state (URL-based) for root level
const queryState = {
contextRowId: useQueryState('contextRowId', parseAsString),
contextRowSource: useQueryState('contextRowSource', parseAsString),
};
// Local state for nested levels
const localState = {
contextRowId: useState<string | null>(null),
contextRowSource: useState<string | null>(null),
};
// Choose which state to use based on nesting level
const activeState = isNested ? localState : queryState;
return {
contextRowId: activeState.contextRowId[0],
contextRowSource: activeState.contextRowSource[0],
setContextRowId: activeState.contextRowId[1],
setContextRowSource: activeState.contextRowSource[1],
};
}
export default function ContextSubpanel({
@ -38,6 +72,8 @@ export default function ContextSubpanel({
dbSqlRowTableConfig,
rowData,
rowId,
breadcrumbPath = [],
onBreadcrumbClick,
}: ContextSubpanelProps) {
const QUERY_KEY_PREFIX = 'context';
const { Timestamp: origTimestamp } = rowData;
@ -55,6 +91,33 @@ export default function ContextSubpanel({
const formWhere = watch('where');
const [debouncedWhere] = useDebouncedValue(formWhere, 1000);
// State management for nested panels
const isNested = breadcrumbPath.length > 0;
const {
contextRowId,
contextRowSource,
setContextRowId,
setContextRowSource,
} = useNestedPanelState(isNested);
const { data: contextRowSidePanelSource } = useSource({
id: contextRowSource || '',
});
const handleContextSidePanelClose = useCallback(() => {
setContextRowId(null);
setContextRowSource(null);
}, [setContextRowId, setContextRowSource]);
const handleRowExpandClick = useCallback(
(rowWhere: string) => {
setContextRowId(rowWhere);
setContextRowSource(source.id);
},
[source.id, setContextRowId, setContextRowSource],
);
const date = useMemo(() => new Date(origTimestamp), [origTimestamp]);
const newDateRange = useMemo(
@ -176,88 +239,107 @@ export default function ContextSubpanel({
]);
return (
config && (
<Flex direction="column" mih="0px" style={{ flexGrow: 1 }}>
<Group justify="space-between" p="sm">
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={generateSegmentedControlData()}
value={contextBy}
onChange={v => setContextBy(v as ContextBy)}
/>
{contextBy === ContextBy.Custom && (
<WhereLanguageControlled
name="whereLanguage"
control={control}
sqlInput={
originalLanguage === 'lucene' ? null : (
<SQLInlineEditorControlled
tableConnections={tcFromSource(source)}
control={control}
name="where"
placeholder="SQL WHERE clause (ex. column = 'foo')"
language="sql"
enableHotkey
size="sm"
/>
)
}
luceneInput={
originalLanguage === 'sql' ? null : (
<SearchInputV2
tableConnections={tcFromSource(source)}
control={control}
name="where"
language="lucene"
placeholder="Lucene where clause (ex. column:value)"
enableHotkey
size="sm"
/>
)
}
<>
{config && (
<Flex direction="column" mih="0px" style={{ flexGrow: 1 }}>
<Group justify="space-between" p="sm">
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={generateSegmentedControlData()}
value={contextBy}
onChange={v => setContextBy(v as ContextBy)}
/>
)}
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={[
{ label: '100ms', value: ms('100ms').toString() },
{ label: '500ms', value: ms('500ms').toString() },
{ label: '1s', value: ms('1s').toString() },
{ label: '5s', value: ms('5s').toString() },
{ label: '30s', value: ms('30s').toString() },
{ label: '1m', value: ms('1m').toString() },
{ label: '5m', value: ms('5m').toString() },
{ label: '15m', value: ms('15m').toString() },
]}
value={range.toString()}
onChange={value => setRange(Number(value))}
/>
</Group>
<Group p="sm">
<div>
{contextBy !== ContextBy.All && (
<Badge size="md" variant="default">
{contextBy}:{CONTEXT_MAPPING[contextBy].value}
</Badge>
{contextBy === ContextBy.Custom && (
<WhereLanguageControlled
name="whereLanguage"
control={control}
sqlInput={
originalLanguage === 'lucene' ? null : (
<SQLInlineEditorControlled
tableConnections={tcFromSource(source)}
control={control}
name="where"
placeholder="SQL WHERE clause (ex. column = 'foo')"
language="sql"
enableHotkey
size="sm"
/>
)
}
luceneInput={
originalLanguage === 'sql' ? null : (
<SearchInputV2
tableConnections={tcFromSource(source)}
control={control}
name="where"
language="lucene"
placeholder="Lucene where clause (ex. column:value)"
enableHotkey
size="sm"
/>
)
}
/>
)}
<Badge size="md" variant="default">
Time range: ±{ms(range / 2)}
</Badge>
<SegmentedControl
bg="dark.7"
color="dark.5"
size="xs"
data={[
{ label: '100ms', value: ms('100ms').toString() },
{ label: '500ms', value: ms('500ms').toString() },
{ label: '1s', value: ms('1s').toString() },
{ label: '5s', value: ms('5s').toString() },
{ label: '30s', value: ms('30s').toString() },
{ label: '1m', value: ms('1m').toString() },
{ label: '5m', value: ms('5m').toString() },
{ label: '15m', value: ms('15m').toString() },
]}
value={range.toString()}
onChange={value => setRange(Number(value))}
/>
</Group>
<Group p="sm">
<div>
{contextBy !== ContextBy.All && (
<Badge size="md" variant="default">
{contextBy}:{CONTEXT_MAPPING[contextBy].value}
</Badge>
)}
<Badge size="md" variant="default">
Time range: ±{ms(range / 2)}
</Badge>
</div>
</Group>
<div style={{ height: '100%', overflow: 'auto' }}>
<DBSqlRowTable
highlightedLineId={rowId}
isLive={false}
config={config}
queryKeyPrefix={QUERY_KEY_PREFIX}
onRowExpandClick={handleRowExpandClick}
/>
</div>
</Group>
<div style={{ height: '100%', overflow: 'auto' }}>
<DBSqlRowTable
highlightedLineId={rowId}
isLive={false}
config={config}
queryKeyPrefix={QUERY_KEY_PREFIX}
/>
</div>
</Flex>
)
</Flex>
)}
{contextRowId && contextRowSidePanelSource && (
<DBRowSidePanel
source={contextRowSidePanelSource}
rowId={contextRowId}
onClose={handleContextSidePanelClose}
isNestedPanel={true}
breadcrumbPath={[
...breadcrumbPath,
{
label: `Surrounding Context`,
rowData,
},
]}
onBreadcrumbClick={onBreadcrumbClick}
/>
)}
</>
);
}

View file

@ -18,7 +18,10 @@ import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { Box, Stack } from '@mantine/core';
import { useClickOutside } from '@mantine/hooks';
import DBRowSidePanelHeader from '@/components/DBRowSidePanelHeader';
import DBRowSidePanelHeader, {
BreadcrumbNavigationCallback,
BreadcrumbPath,
} from '@/components/DBRowSidePanelHeader';
import useResizable from '@/hooks/useResizable';
import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements';
import { getEventBody } from '@/source';
@ -71,6 +74,8 @@ type DBRowSidePanelProps = {
rowId: string | undefined;
onClose: () => void;
isNestedPanel?: boolean;
breadcrumbPath?: BreadcrumbPath;
onBreadcrumbClick?: BreadcrumbNavigationCallback;
};
const DBRowSidePanel = ({
@ -78,6 +83,9 @@ const DBRowSidePanel = ({
source,
isNestedPanel = false,
setSubDrawerOpen,
onClose,
breadcrumbPath = [],
onBreadcrumbClick,
}: DBRowSidePanelProps & {
setSubDrawerOpen: Dispatch<SetStateAction<boolean>>;
}) => {
@ -92,6 +100,34 @@ const DBRowSidePanel = ({
const { dbSqlRowTableConfig } = useContext(RowSidePanelContext);
const handleBreadcrumbClick = useCallback(
(targetLevel: number) => {
// Current panel's level in the hierarchy
const currentLevel = breadcrumbPath.length;
// 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 hasOverviewPanel = useMemo(() => {
if (
source.resourceAttributesExpression ||
@ -230,6 +266,8 @@ const DBRowSidePanel = ({
mainContent={mainContent}
mainContentHeader={mainContentColumn}
severityText={severityText}
breadcrumbPath={breadcrumbPath}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</Box>
{/* <SidePanelHeader
@ -349,6 +387,8 @@ const DBRowSidePanel = ({
dbSqlRowTableConfig={dbSqlRowTableConfig}
rowData={normalizedRow}
rowId={rowId}
breadcrumbPath={breadcrumbPath}
onBreadcrumbClick={handleBreadcrumbClick}
/>
</ErrorBoundary>
)}
@ -405,6 +445,8 @@ export default function DBRowSidePanelErrorBoundary({
rowId,
source,
isNestedPanel,
breadcrumbPath = [],
onBreadcrumbClick,
}: DBRowSidePanelProps) {
const contextZIndex = useZIndex();
const drawerZIndex = contextZIndex + 10;
@ -474,7 +516,9 @@ export default function DBRowSidePanelErrorBoundary({
rowId={rowId}
onClose={_onClose}
isNestedPanel={isNestedPanel}
breadcrumbPath={breadcrumbPath}
setSubDrawerOpen={setSubDrawerOpen}
onBreadcrumbClick={onBreadcrumbClick}
/>
</ErrorBoundary>
</div>

View file

@ -2,10 +2,20 @@ import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Button, Flex, Paper, Text } from '@mantine/core';
import {
Box,
Breadcrumbs,
Button,
Flex,
Paper,
Text,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import EventTag from '@/components/EventTag';
import { FormatTime } from '@/useFormatTime';
@ -19,18 +29,111 @@ const isValidDate = (date: Date) => 'getTime' in date && !isNaN(date.getTime());
const MAX_MAIN_CONTENT_LENGTH = 2000;
// Types for breadcrumb navigation
export type BreadcrumbEntry = {
label: string;
rowData?: Record<string, any>;
};
export type BreadcrumbPath = BreadcrumbEntry[];
// Navigation callback type - called when user wants to navigate to a specific level
export type BreadcrumbNavigationCallback = (targetLevel: number) => void;
function getBodyTextForBreadcrumb(rowData: Record<string, any>): string {
const bodyText = (rowData.__hdx_body || '').trim();
const BREADCRUMB_TOOLTIP_MAX_LENGTH = 200;
const BREADCRUMB_TOOLTIP_TRUNCATED_LENGTH = 197;
return bodyText.length > BREADCRUMB_TOOLTIP_MAX_LENGTH
? `${bodyText.substring(0, BREADCRUMB_TOOLTIP_TRUNCATED_LENGTH)}...`
: bodyText;
}
function BreadcrumbNavigation({
breadcrumbPath,
onNavigateToLevel,
}: {
breadcrumbPath: BreadcrumbPath;
onNavigateToLevel?: BreadcrumbNavigationCallback;
}) {
const handleBreadcrumbItemClick = useCallback(
(clickedIndex: number) => {
// Navigate to the clicked breadcrumb level
// This will close all panels above this level
onNavigateToLevel?.(clickedIndex);
},
[onNavigateToLevel],
);
const breadcrumbItems = useMemo(() => {
if (breadcrumbPath.length === 0) return [];
const items = [];
// Add all previous levels from breadcrumbPath
breadcrumbPath.forEach((crumb, index) => {
const tooltipText = crumb.rowData
? getBodyTextForBreadcrumb(crumb.rowData)
: '';
items.push(
<Tooltip
key={`crumb-${index}`}
label={tooltipText}
disabled={!tooltipText}
position="bottom"
withArrow
>
<UnstyledButton
onClick={() => handleBreadcrumbItemClick(index)}
style={{ textDecoration: 'none' }}
>
<Text size="sm" c="blue.4" style={{ cursor: 'pointer' }}>
{index === 0 ? 'Original Event' : crumb.label}
</Text>
</UnstyledButton>
</Tooltip>,
);
});
// Add current level
items.push(
<Text key="current" size="sm" c="gray.2">
Selected Event
</Text>,
);
return items;
}, [breadcrumbPath, handleBreadcrumbItemClick]);
if (breadcrumbPath.length === 0) return null;
return (
<Box mb="sm" pb="sm" className="border-bottom border-dark">
<Breadcrumbs separator="" separatorMargin="xs">
{breadcrumbItems}
</Breadcrumbs>
</Box>
);
}
export default function DBRowSidePanelHeader({
tags,
mainContent = '',
mainContentHeader,
date,
severityText,
breadcrumbPath = [],
onBreadcrumbClick,
}: {
date: Date;
mainContent?: string;
mainContentHeader?: string;
tags: Record<string, string>;
severityText?: string;
breadcrumbPath?: BreadcrumbPath;
onBreadcrumbClick?: BreadcrumbNavigationCallback;
}) {
const [bodyExpanded, setBodyExpanded] = React.useState(false);
const { onPropertyAddClick, generateSearchUrl } =
@ -85,6 +188,13 @@ export default function DBRowSidePanelHeader({
return (
<>
{/* Breadcrumb navigation */}
<BreadcrumbNavigation
breadcrumbPath={breadcrumbPath}
onNavigateToLevel={onBreadcrumbClick}
/>
{/* Event timestamp and severity */}
<Flex>
{severityText && <LogLevel level={severityText} />}
{severityText && isValidDate(date) && (