mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add click + sidepanel support to items within surrounding context (#989)
Fixes HDX-1951
This commit is contained in:
parent
229b511f64
commit
86115fa58a
4 changed files with 323 additions and 82 deletions
5
.changeset/sharp-snails-warn.md
Normal file
5
.changeset/sharp-snails-warn.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add click + sidepanel support to items within surrounding context
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue