mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
Add copy-to-clipboard buttons in RawLogTable for log line data and URLs (#1227)
Co-authored-by: Mike Shi <mike@hyperdx.io>
This commit is contained in:
parent
dbf16827a3
commit
eaff49293c
14 changed files with 660 additions and 126 deletions
5
.changeset/tricky-apples-complain.md
Normal file
5
.changeset/tricky-apples-complain.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
Add toggle filters button, copy field, and per-row copy-to-clipboard for JSON data and modal URLs in RawLogTable
|
||||
|
|
@ -3,20 +3,12 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"rootDir": ".",
|
||||
"outDir": "build",
|
||||
"moduleResolution": "Node16"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"migrations",
|
||||
"scripts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
"include": ["src", "migrations", "scripts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1226,7 +1226,11 @@ function DBSearchPage() {
|
|||
/>
|
||||
)}
|
||||
<OnboardingModal />
|
||||
<form data-testid="search-form" onSubmit={onFormSubmit}>
|
||||
<form
|
||||
data-testid="search-form"
|
||||
onSubmit={onFormSubmit}
|
||||
className={searchPageStyles.searchForm}
|
||||
>
|
||||
{/* <DevTool control={control} /> */}
|
||||
<Flex gap="sm" px="sm" pt="sm" wrap="nowrap">
|
||||
<Group gap="4px" wrap="nowrap">
|
||||
|
|
@ -1497,7 +1501,6 @@ function DBSearchPage() {
|
|||
)}
|
||||
<Flex
|
||||
direction="column"
|
||||
mt="sm"
|
||||
style={{ overflow: 'hidden', height: '100%' }}
|
||||
className="bg-hdx-dark"
|
||||
>
|
||||
|
|
@ -1535,12 +1538,8 @@ function DBSearchPage() {
|
|||
{analysisMode === 'pattern' &&
|
||||
histogramTimeChartConfig != null && (
|
||||
<Flex direction="column" w="100%" gap="0px">
|
||||
<Box style={{ height: 20, minHeight: 20 }} p="xs" pb="md">
|
||||
<Group
|
||||
justify="space-between"
|
||||
mb={4}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box className={searchPageStyles.searchStatsContainer}>
|
||||
<Group justify="space-between" style={{ width: '100%' }}>
|
||||
<SearchTotalCountChart
|
||||
config={histogramTimeChartConfig}
|
||||
queryKeyPrefix={QUERY_KEY_PREFIX}
|
||||
|
|
@ -1555,12 +1554,7 @@ function DBSearchPage() {
|
|||
</Group>
|
||||
</Box>
|
||||
{!hasQueryError && (
|
||||
<Box
|
||||
style={{ height: 120, minHeight: 120 }}
|
||||
p="xs"
|
||||
pb="md"
|
||||
mb="md"
|
||||
>
|
||||
<Box className={searchPageStyles.timeChartContainer}>
|
||||
<DBTimeChart
|
||||
sourceId={searchedConfig.source ?? undefined}
|
||||
showLegend={false}
|
||||
|
|
@ -1646,32 +1640,40 @@ function DBSearchPage() {
|
|||
chartConfig &&
|
||||
histogramTimeChartConfig && (
|
||||
<>
|
||||
<Box style={{ height: 20, minHeight: 20 }} p="xs" pb="md">
|
||||
<Box className={searchPageStyles.searchStatsContainer}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
mb={4}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<SearchTotalCountChart
|
||||
config={histogramTimeChartConfig}
|
||||
queryKeyPrefix={QUERY_KEY_PREFIX}
|
||||
/>
|
||||
<SearchNumRows
|
||||
config={{
|
||||
...chartConfig,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
enabled={isReady}
|
||||
/>
|
||||
<Group gap="sm" align="center">
|
||||
{shouldShowLiveModeHint &&
|
||||
analysisMode === 'results' &&
|
||||
denoiseResults != true && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="outline"
|
||||
onClick={handleResumeLiveTail}
|
||||
>
|
||||
<i className="bi text-success bi-lightning-charge-fill me-2" />
|
||||
Resume Live Tail
|
||||
</Button>
|
||||
)}
|
||||
<SearchNumRows
|
||||
config={{
|
||||
...chartConfig,
|
||||
dateRange: searchedTimeRange,
|
||||
}}
|
||||
enabled={isReady}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{!hasQueryError && (
|
||||
<Box
|
||||
style={{ height: 120, minHeight: 120 }}
|
||||
p="xs"
|
||||
pb="md"
|
||||
mb="md"
|
||||
>
|
||||
<Box className={searchPageStyles.timeChartContainer}>
|
||||
<DBTimeChart
|
||||
sourceId={searchedConfig.source ?? undefined}
|
||||
showLegend={false}
|
||||
|
|
@ -1803,31 +1805,6 @@ function DBSearchPage() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{shouldShowLiveModeHint &&
|
||||
analysisMode === 'results' &&
|
||||
denoiseResults != true && (
|
||||
<div
|
||||
className="d-flex justify-content-center"
|
||||
style={{ height: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
top: -20,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="outline"
|
||||
onClick={handleResumeLiveTail}
|
||||
>
|
||||
<i className="bi text-success bi-lightning-charge-fill me-2" />
|
||||
Resume Live Tail
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chartConfig &&
|
||||
searchedConfig.source &&
|
||||
dbSqlRowTableConfig &&
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|||
import Link from 'next/link';
|
||||
import cx from 'classnames';
|
||||
import { Flex, Text, UnstyledButton } from '@mantine/core';
|
||||
import { IconGripVertical } from '@tabler/icons-react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ import 'react-modern-drawer/dist/index.css';
|
|||
import styles from '@/../styles/LogSidePanel.module.scss';
|
||||
|
||||
export type RowSidePanelContextProps = {
|
||||
onPropertyAddClick?: (keyPath: string, value: string) => void;
|
||||
onPropertyAddClick?: (
|
||||
keyPath: string,
|
||||
value: string,
|
||||
action?: 'only' | 'exclude' | 'include',
|
||||
) => void;
|
||||
generateSearchUrl?: ({
|
||||
where,
|
||||
whereLanguage,
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ import {
|
|||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconDotsVertical,
|
||||
IconCode,
|
||||
IconDownload,
|
||||
IconRotateClockwise,
|
||||
IconSettings,
|
||||
IconTextWrap,
|
||||
} from '@tabler/icons-react';
|
||||
import { FetchNextPageOptions, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
|
|
@ -84,9 +86,10 @@ import {
|
|||
logLevelColor,
|
||||
useLocalStorage,
|
||||
usePrevious,
|
||||
useWindowSize,
|
||||
} from '@/utils';
|
||||
|
||||
import DBRowTableFieldWithPopover from './DBTable/DBRowTableFieldWithPopover';
|
||||
import DBRowTableRowButtons from './DBTable/DBRowTableRowButtons';
|
||||
import TableHeader from './DBTable/TableHeader';
|
||||
import { SQLPreview } from './ChartSQLPreview';
|
||||
import { CsvExportButton } from './CsvExportButton';
|
||||
|
|
@ -156,7 +159,7 @@ function inferLogLevelColumn(rows: Record<string, any>[]) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const PatternTrendChartTooltip = (props: any) => {
|
||||
const PatternTrendChartTooltip = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -208,7 +211,7 @@ export const PatternTrendChart = ({
|
|||
// tickFormatter={tick =>
|
||||
// format(new Date(tick * 1000), 'MMM d HH:mm')
|
||||
// }
|
||||
tickFormatter={tick => ''}
|
||||
tickFormatter={() => ''}
|
||||
minTickGap={50}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
|
|
@ -317,6 +320,7 @@ export const RawLogTable = memo(
|
|||
onSortingChange,
|
||||
sortOrder,
|
||||
showExpandButton = true,
|
||||
getRowWhere,
|
||||
}: {
|
||||
wrapLines?: boolean;
|
||||
displayedColumns: string[];
|
||||
|
|
@ -356,6 +360,7 @@ export const RawLogTable = memo(
|
|||
enableSorting?: boolean;
|
||||
sortOrder?: SortingState;
|
||||
onSortingChange?: (v: SortingState | null) => void;
|
||||
getRowWhere?: (row: Record<string, any>) => string;
|
||||
}) => {
|
||||
const generateRowMatcher = generateRowId;
|
||||
|
||||
|
|
@ -379,14 +384,12 @@ export const RawLogTable = memo(
|
|||
}, [rows, dedupRows, generateRowMatcher]);
|
||||
|
||||
const _onRowExpandClick = useCallback(
|
||||
({ __hyperdx_id, ...row }: Record<string, any>) => {
|
||||
(row: Record<string, any>) => {
|
||||
onRowDetailsClick?.(row);
|
||||
},
|
||||
[onRowDetailsClick],
|
||||
);
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const isSmallScreen = (width ?? 1000) < 900;
|
||||
const {
|
||||
userPreferences: { isUTC },
|
||||
} = useUserPreferences();
|
||||
|
|
@ -749,32 +752,35 @@ export const RawLogTable = memo(
|
|||
header={header}
|
||||
isLast={isLast}
|
||||
lastItemButtons={
|
||||
<>
|
||||
<Group gap={8} mr={8}>
|
||||
{tableId &&
|
||||
Object.keys(columnSizeStorage).length > 0 && (
|
||||
<div
|
||||
className="fs-8 text-muted-hover disabled"
|
||||
role="button"
|
||||
<UnstyledButton
|
||||
onClick={() => setColumnSizeStorage({})}
|
||||
title="Reset Column Widths"
|
||||
>
|
||||
<i className="bi bi-arrow-clockwise" />
|
||||
</div>
|
||||
<MantineTooltip label="Reset Column Widths">
|
||||
<IconRotateClockwise size={16} />
|
||||
</MantineTooltip>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{config && (
|
||||
<UnstyledButton
|
||||
onClick={() => handleSqlModalOpen(true)}
|
||||
title="Show generated SQL"
|
||||
tabIndex={0}
|
||||
>
|
||||
<MantineTooltip label="Show generated SQL">
|
||||
<i className="bi bi-code-square" />
|
||||
<IconCode size={16} />
|
||||
</MantineTooltip>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
<UnstyledButton
|
||||
onClick={() => setWrapLinesEnabled(prev => !prev)}
|
||||
title="Wrap lines"
|
||||
>
|
||||
<MantineTooltip label="Wrap lines">
|
||||
<i className="bi bi-text-wrap" />
|
||||
<IconTextWrap size={16} />
|
||||
</MantineTooltip>
|
||||
</UnstyledButton>
|
||||
|
||||
|
|
@ -786,19 +792,20 @@ export const RawLogTable = memo(
|
|||
<MantineTooltip
|
||||
label={`Download table as CSV (max ${maxRows.toLocaleString()} rows)${isLimited ? ' - data truncated' : ''}`}
|
||||
>
|
||||
<i className="bi bi-download" />
|
||||
<IconDownload size={16} />
|
||||
</MantineTooltip>
|
||||
</CsvExportButton>
|
||||
{onSettingsClick != null && (
|
||||
<div
|
||||
className="fs-8 text-muted-hover"
|
||||
role="button"
|
||||
<UnstyledButton
|
||||
onClick={() => onSettingsClick()}
|
||||
title="Settings"
|
||||
>
|
||||
<i className="bi bi-gear-fill" />
|
||||
</div>
|
||||
<MantineTooltip label="Settings">
|
||||
<IconSettings size={16} />
|
||||
</MantineTooltip>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
@ -841,14 +848,17 @@ export const RawLogTable = memo(
|
|||
</td>
|
||||
)}
|
||||
|
||||
{/* Content columns grouped as one button */}
|
||||
{/* Content columns grouped back to preserve row hover/click */}
|
||||
<td
|
||||
className="align-top overflow-hidden p-0"
|
||||
colSpan={columns.length - (showExpandButton ? 1 : 0)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.rowContentButton}
|
||||
className={cx(styles.rowContentButton, {
|
||||
[styles.isWrapped]: wrapLinesEnabled,
|
||||
[styles.isTruncated]: !wrapLinesEnabled,
|
||||
})}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
_onRowExpandClick(row.original);
|
||||
|
|
@ -857,25 +867,19 @@ export const RawLogTable = memo(
|
|||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.slice(showExpandButton ? 1 : 0) // Skip expand column
|
||||
.map((cell, cellIndex) => {
|
||||
.slice(showExpandButton ? 1 : 0) // Skip expand
|
||||
.map(cell => {
|
||||
const columnCustomClassName = (
|
||||
cell.column.columnDef.meta as any
|
||||
)?.className;
|
||||
const columnSize = cell.column.getSize();
|
||||
const totalContentCells =
|
||||
row.getVisibleCells().length -
|
||||
(showExpandButton ? 1 : 0);
|
||||
const cellValue = cell.getValue<any>();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={cx(
|
||||
'flex-shrink-0 overflow-hidden',
|
||||
{
|
||||
'text-break': wrapLinesEnabled,
|
||||
'text-truncate': !wrapLinesEnabled,
|
||||
},
|
||||
'flex-shrink-0 overflow-hidden position-relative',
|
||||
columnCustomClassName,
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -889,13 +893,36 @@ export const RawLogTable = memo(
|
|||
: 'none',
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
<div className={styles.fieldTextContainer}>
|
||||
<DBRowTableFieldWithPopover
|
||||
cellValue={cellValue}
|
||||
wrapLinesEnabled={wrapLinesEnabled}
|
||||
columnName={
|
||||
(cell.column.columnDef.meta as any)
|
||||
?.column
|
||||
}
|
||||
isChart={
|
||||
(cell.column.columnDef.meta as any)
|
||||
?.column === '__hdx_pattern_trend'
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</DBRowTableFieldWithPopover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Row-level copy buttons */}
|
||||
{getRowWhere && (
|
||||
<DBRowTableRowButtons
|
||||
row={row.original}
|
||||
getRowWhere={getRowWhere}
|
||||
sourceId={source?.id}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -1402,6 +1429,7 @@ function DBSqlRowTableComponent({
|
|||
enableSorting={true}
|
||||
onSortingChange={_onSortingChange}
|
||||
sortOrder={orderByArray}
|
||||
getRowWhere={getRowWhere}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Popover } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';
|
||||
|
||||
import { RowSidePanelContext } from '../DBRowSidePanel';
|
||||
|
||||
import DBRowTableIconButton from './DBRowTableIconButton';
|
||||
|
||||
import styles from '../../../styles/LogTable.module.scss';
|
||||
|
||||
export interface DBRowTableFieldWithPopoverProps {
|
||||
children: React.ReactNode;
|
||||
cellValue: unknown;
|
||||
wrapLinesEnabled: boolean;
|
||||
columnName?: string;
|
||||
isChart?: boolean;
|
||||
}
|
||||
|
||||
export const DBRowTableFieldWithPopover = ({
|
||||
children,
|
||||
cellValue,
|
||||
wrapLinesEnabled,
|
||||
columnName,
|
||||
isChart = false,
|
||||
}: DBRowTableFieldWithPopoverProps) => {
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [hoverDisabled, setHoverDisabled] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const hoverDisableTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Cleanup timeouts on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (hoverDisableTimeoutRef.current) {
|
||||
clearTimeout(hoverDisableTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get filter functionality from context
|
||||
const { onPropertyAddClick } = useContext(RowSidePanelContext);
|
||||
|
||||
// Check if we have both the column name and filter function available
|
||||
// Only show filter for ServiceName and SeverityText
|
||||
const canFilter =
|
||||
columnName &&
|
||||
(columnName === 'ServiceName' || columnName === 'SeverityText') &&
|
||||
onPropertyAddClick &&
|
||||
cellValue != null;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (hoverDisabled) return;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
open();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
close();
|
||||
}, 100); // Small delay to allow moving to popover
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
close();
|
||||
setHoverDisabled(true);
|
||||
|
||||
if (hoverDisableTimeoutRef.current) {
|
||||
clearTimeout(hoverDisableTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Prevent the popover from immediately reopening on hover for 1 second
|
||||
// This gives users time to move their cursor or interact with modals
|
||||
// without the popover interfering with their intended action
|
||||
hoverDisableTimeoutRef.current = setTimeout(() => {
|
||||
setHoverDisabled(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const copyFieldValue = async () => {
|
||||
try {
|
||||
const value =
|
||||
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
|
||||
await navigator.clipboard.writeText(value);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
// Optionally show an error toast notification to the user
|
||||
}
|
||||
};
|
||||
|
||||
const addFilter = () => {
|
||||
if (canFilter) {
|
||||
const value =
|
||||
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
|
||||
onPropertyAddClick(columnName, value, 'include');
|
||||
handleClick(); // Close the popover
|
||||
}
|
||||
};
|
||||
|
||||
const excludeFilter = () => {
|
||||
if (canFilter) {
|
||||
const value =
|
||||
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
|
||||
onPropertyAddClick(columnName, value, 'exclude');
|
||||
handleClick(); // Close the popover
|
||||
}
|
||||
};
|
||||
|
||||
const buttonSize = 20;
|
||||
const gapSize = 4;
|
||||
const numberOfButtons = canFilter ? 3 : 1; // Copy + Include Filter + Exclude Filter (if filtering available)
|
||||
const numberOfGaps = numberOfButtons - 1;
|
||||
|
||||
// If it's a chart, just render the children without popover functionality
|
||||
if (isChart) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.fieldText, {
|
||||
[styles.chart]: isChart,
|
||||
})}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.fieldText, {
|
||||
[styles.truncated]: !wrapLinesEnabled && !isChart,
|
||||
[styles.wrapped]: wrapLinesEnabled,
|
||||
[styles.chart]: isChart,
|
||||
})}
|
||||
>
|
||||
<Popover
|
||||
width={buttonSize * numberOfButtons + gapSize * numberOfGaps}
|
||||
position="top-start"
|
||||
offset={5}
|
||||
opened={opened}
|
||||
zIndex={1}
|
||||
>
|
||||
<Popover.Target>
|
||||
<span
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={styles.fieldTextPopover}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: `${gapSize}px`,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<DBRowTableIconButton
|
||||
onClick={copyFieldValue}
|
||||
variant="copy"
|
||||
isActive={isCopied}
|
||||
title={isCopied ? 'Copied!' : 'Copy field value'}
|
||||
>
|
||||
<IconCopy size={14} />
|
||||
</DBRowTableIconButton>
|
||||
{canFilter && (
|
||||
<>
|
||||
<DBRowTableIconButton
|
||||
onClick={addFilter}
|
||||
variant="copy"
|
||||
title="Toggle filter for this value"
|
||||
>
|
||||
<IconFilter size={14} />
|
||||
</DBRowTableIconButton>
|
||||
<DBRowTableIconButton
|
||||
onClick={excludeFilter}
|
||||
variant="copy"
|
||||
title="Exclude this value"
|
||||
>
|
||||
<IconFilterX size={14} />
|
||||
</DBRowTableIconButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DBRowTableFieldWithPopover;
|
||||
63
packages/app/src/components/DBTable/DBRowTableIconButton.tsx
Normal file
63
packages/app/src/components/DBTable/DBRowTableIconButton.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
|
||||
import styles from '../../../styles/LogTable.module.scss';
|
||||
|
||||
export interface DBRowTableIconButtonProps {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
title?: string;
|
||||
tabIndex?: number;
|
||||
children: React.ReactNode;
|
||||
variant?: 'copy' | 'default';
|
||||
isActive?: boolean;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export const DBRowTableIconButton: React.FC<DBRowTableIconButtonProps> = ({
|
||||
onClick,
|
||||
className,
|
||||
title,
|
||||
tabIndex = -1,
|
||||
children,
|
||||
variant = 'default',
|
||||
isActive = false,
|
||||
iconSize = 14,
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
};
|
||||
|
||||
const baseClasses =
|
||||
variant === 'copy'
|
||||
? cx('text-muted-hover', styles.iconActionButton, {
|
||||
[styles.copied]: isActive,
|
||||
})
|
||||
: className;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={title}
|
||||
position="top"
|
||||
withArrow
|
||||
disabled={!title}
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
fz="xs"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
className={baseClasses}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{isActive ? <IconCheck size={iconSize} /> : children}
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default DBRowTableIconButton;
|
||||
105
packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
Normal file
105
packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
import { IconCopy, IconLink } from '@tabler/icons-react';
|
||||
|
||||
import DBRowTableIconButton from './DBRowTableIconButton';
|
||||
|
||||
import styles from '../../../styles/LogTable.module.scss';
|
||||
|
||||
export interface DBRowTableRowButtonsProps {
|
||||
row: Record<string, any>;
|
||||
getRowWhere: (row: Record<string, any>) => string;
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
export const DBRowTableRowButtons: React.FC<DBRowTableRowButtonsProps> = ({
|
||||
row,
|
||||
getRowWhere,
|
||||
sourceId,
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [isUrlCopied, setIsUrlCopied] = useState(false);
|
||||
|
||||
const copyRowData = async () => {
|
||||
try {
|
||||
// Filter out internal metadata fields that start with __ or are generated IDs
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __hyperdx_id, ...cleanRow } = row;
|
||||
|
||||
// Parse JSON string fields to make them proper JSON objects
|
||||
const parsedRow = Object.entries(cleanRow).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (
|
||||
(typeof value === 'string' && value.startsWith('{')) ||
|
||||
value.startsWith('[')
|
||||
) {
|
||||
try {
|
||||
acc[key] = JSON.parse(value);
|
||||
} catch {
|
||||
// If parsing fails, keep the original string
|
||||
acc[key] = value;
|
||||
}
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
const rowData = JSON.stringify(parsedRow, null, 2);
|
||||
await navigator.clipboard.writeText(rowData);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy row data to clipboard:', error);
|
||||
// Optionally show an error toast notification to the user
|
||||
}
|
||||
};
|
||||
|
||||
const copyRowUrl = async () => {
|
||||
try {
|
||||
const rowWhere = getRowWhere(row);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
// Add the row identifier as query parameters
|
||||
currentUrl.searchParams.set('rowWhere', rowWhere);
|
||||
if (sourceId) {
|
||||
currentUrl.searchParams.set('rowSource', sourceId);
|
||||
}
|
||||
await navigator.clipboard.writeText(currentUrl.toString());
|
||||
setIsUrlCopied(true);
|
||||
setTimeout(() => setIsUrlCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL to clipboard:', error);
|
||||
// Optionally show an error toast notification to the user
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.rowButtons}>
|
||||
<DBRowTableIconButton
|
||||
onClick={copyRowData}
|
||||
variant="copy"
|
||||
isActive={isCopied}
|
||||
title={
|
||||
isCopied ? 'Copied entire row as JSON!' : 'Copy entire row as JSON'
|
||||
}
|
||||
>
|
||||
<IconCopy size={12} />
|
||||
</DBRowTableIconButton>
|
||||
<DBRowTableIconButton
|
||||
onClick={copyRowUrl}
|
||||
variant="copy"
|
||||
isActive={isUrlCopied}
|
||||
title={
|
||||
isUrlCopied
|
||||
? 'Copied shareable link!'
|
||||
: 'Copy shareable link to this specific row'
|
||||
}
|
||||
>
|
||||
<IconLink size={12} />
|
||||
</DBRowTableIconButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DBRowTableRowButtons;
|
||||
|
|
@ -3,7 +3,7 @@ import { Button, Group, Text } from '@mantine/core';
|
|||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
} from '@tabler/icons-react';
|
||||
import { flexRender, Header } from '@tanstack/react-table';
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ export default function TableHeader({
|
|||
header.column.getIsResizing() && 'isResizing',
|
||||
)}
|
||||
>
|
||||
<IconDotsVertical size={12} />
|
||||
<IconGripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isLast && (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default function LogLevel({
|
|||
|
||||
return (
|
||||
<Text
|
||||
component="span"
|
||||
size="xs"
|
||||
c={
|
||||
levelClass === 'error'
|
||||
|
|
|
|||
|
|
@ -143,7 +143,12 @@ export default function useRowWhere({
|
|||
);
|
||||
|
||||
return useCallback(
|
||||
(row: Record<string, any>) => processRowToWhereClause(row, columnMap),
|
||||
(row: Record<string, any>) => {
|
||||
// Filter out synthetic columns that aren't in the database schema
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __hyperdx_id, ...dbRow } = row;
|
||||
return processRowToWhereClause(dbRow, columnMap);
|
||||
},
|
||||
[columnMap],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,44 @@
|
|||
@import './variables';
|
||||
|
||||
$button-height: 18px;
|
||||
|
||||
.table {
|
||||
table-layout: fixed;
|
||||
border-spacing: 0 2px;
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
}
|
||||
|
||||
.tableHead {
|
||||
background: inherit;
|
||||
background: var(--mantine-color-dark-8);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
position: relative;
|
||||
|
||||
&__selected {
|
||||
background-color: $slate-800;
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:hover .rowButtons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover .rowContentButton {
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
&:has(.expandButton:hover) .rowButtons {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
&:has(.expandButton:hover) .rowContentButton {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedRowContent {
|
||||
|
|
@ -37,21 +59,22 @@
|
|||
justify-content: center;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
border-radius: 4px;
|
||||
min-height: $button-height;
|
||||
|
||||
&:hover {
|
||||
background-color: $slate-800;
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
background-color: $slate-800;
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background-color: $slate-800;
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
svg {
|
||||
|
|
@ -66,8 +89,8 @@
|
|||
|
||||
.expandButtonSeparator {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
border-right: 1px solid $slate-800;
|
||||
height: calc($button-height - 8px);
|
||||
border-right: 1px solid var(--mantine-color-dark-4);
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
display: inline-block;
|
||||
|
|
@ -80,28 +103,123 @@
|
|||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
border-radius: 4px;
|
||||
min-height: $button-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: $slate-800;
|
||||
&:hover .rowButtons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
background-color: $slate-800;
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background-color: $slate-800;
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
&.isWrapped {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.isTruncated {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.rowButtons {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 0;
|
||||
padding-left: 10px;
|
||||
padding-right: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
z-index: 0;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.iconActionButton {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--mantine-color-dark-4);
|
||||
border: 1px solid var(--mantine-color-dark-3);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.copied {
|
||||
color: var(--mantine-color-green-6);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldTextContainer {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldText {
|
||||
overflow: hidden;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
> span:hover {
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-dark-2) 30%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.chart {
|
||||
overflow: visible;
|
||||
min-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldText.truncated {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-height: $button-height;
|
||||
}
|
||||
|
||||
.fieldText.wrapped {
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.fieldTextPopover {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
flex-grow: 0;
|
||||
border-right: 1px solid var(--mantine-color-dark-6);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
z-index: 3; // higher z-index to be above other elements
|
||||
|
||||
:global {
|
||||
.mantine-TextInput-wrapper {
|
||||
|
|
@ -151,3 +151,24 @@
|
|||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.searchStatsContainer {
|
||||
background-color: $bg-hdx-dark;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
padding-block: var(--mantine-spacing-xxs);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.timeChartContainer {
|
||||
background-color: $bg-hdx-dark;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
padding-block: var(--mantine-spacing-xxs);
|
||||
height: 120px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.searchForm {
|
||||
background-color: $body-bg;
|
||||
z-index: 4;
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue