diff --git a/.changeset/tricky-apples-complain.md b/.changeset/tricky-apples-complain.md new file mode 100644 index 000000000..c41af8d7e --- /dev/null +++ b/.changeset/tricky-apples-complain.md @@ -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 diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 55b89a84f..dd3f57570 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -3,20 +3,12 @@ "compilerOptions": { "baseUrl": "./src", "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] }, "rootDir": ".", "outDir": "build", "moduleResolution": "Node16" }, - "include": [ - "src", - "migrations", - "scripts" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["src", "migrations", "scripts"], + "exclude": ["node_modules"] +} diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index e58a4a100..f6801374d 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1226,7 +1226,11 @@ function DBSearchPage() { /> )} -
+ {/* */} @@ -1497,7 +1501,6 @@ function DBSearchPage() { )} @@ -1535,12 +1538,8 @@ function DBSearchPage() { {analysisMode === 'pattern' && histogramTimeChartConfig != null && ( - - + + {!hasQueryError && ( - + - + - + + {shouldShowLiveModeHint && + analysisMode === 'results' && + denoiseResults != true && ( + + )} + + {!hasQueryError && ( - + ) : ( <> - {shouldShowLiveModeHint && - analysisMode === 'results' && - denoiseResults != true && ( -
-
- -
-
- )} {chartConfig && searchedConfig.source && dbSqlRowTableConfig && diff --git a/packages/app/src/HDXMultiSeriesTableChart.tsx b/packages/app/src/HDXMultiSeriesTableChart.tsx index f929e79b9..1fb595d8d 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.tsx +++ b/packages/app/src/HDXMultiSeriesTableChart.tsx @@ -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, diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index c12a895cf..733eb1a19 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -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, diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 11f4b6fd5..496ba1600 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -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[]) { 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; }) => { const generateRowMatcher = generateRowId; @@ -379,14 +384,12 @@ export const RawLogTable = memo( }, [rows, dedupRows, generateRowMatcher]); const _onRowExpandClick = useCallback( - ({ __hyperdx_id, ...row }: Record) => { + (row: Record) => { 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={ - <> + {tableId && Object.keys(columnSizeStorage).length > 0 && ( -
setColumnSizeStorage({})} title="Reset Column Widths" > - -
+ + + + )} {config && ( handleSqlModalOpen(true)} + title="Show generated SQL" + tabIndex={0} > - + )} setWrapLinesEnabled(prev => !prev)} + title="Wrap lines" > - + @@ -786,19 +792,20 @@ export const RawLogTable = memo( - + {onSettingsClick != null && ( -
onSettingsClick()} + title="Settings" > - -
+ + + + )} - +
} /> ); @@ -841,14 +848,17 @@ export const RawLogTable = memo( )} - {/* Content columns grouped as one button */} + {/* Content columns grouped back to preserve row hover/click */} @@ -1402,6 +1429,7 @@ function DBSqlRowTableComponent({ enableSorting={true} onSortingChange={_onSortingChange} sortOrder={orderByArray} + getRowWhere={getRowWhere} /> ); diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx new file mode 100644 index 000000000..18206b460 --- /dev/null +++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx @@ -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(); + const hoverDisableTimeoutRef = useRef(); + + // 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 ( +
+ {children} +
+ ); + } + + return ( +
+ + + + + +
+ + + + {canFilter && ( + <> + + + + + + + + )} +
+
+
+
+ ); +}; + +export default DBRowTableFieldWithPopover; diff --git a/packages/app/src/components/DBTable/DBRowTableIconButton.tsx b/packages/app/src/components/DBTable/DBRowTableIconButton.tsx new file mode 100644 index 000000000..c409c477a --- /dev/null +++ b/packages/app/src/components/DBTable/DBRowTableIconButton.tsx @@ -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 = ({ + 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 ( + + + {isActive ? : children} + + + ); +}; + +export default DBRowTableIconButton; diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx new file mode 100644 index 000000000..4619701e0 --- /dev/null +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -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; + getRowWhere: (row: Record) => string; + sourceId?: string; +} + +export const DBRowTableRowButtons: React.FC = ({ + 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, + ); + + 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 ( +
+ + + + + + +
+ ); +}; + +export default DBRowTableRowButtons; diff --git a/packages/app/src/components/DBTable/TableHeader.tsx b/packages/app/src/components/DBTable/TableHeader.tsx index 8eef4eb9e..e25338df9 100644 --- a/packages/app/src/components/DBTable/TableHeader.tsx +++ b/packages/app/src/components/DBTable/TableHeader.tsx @@ -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', )} > - + )} {isLast && ( diff --git a/packages/app/src/components/LogLevel.tsx b/packages/app/src/components/LogLevel.tsx index 920e7c2a7..c3d148416 100644 --- a/packages/app/src/components/LogLevel.tsx +++ b/packages/app/src/components/LogLevel.tsx @@ -10,6 +10,7 @@ export default function LogLevel({ return ( ) => processRowToWhereClause(row, columnMap), + (row: Record) => { + // 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], ); } diff --git a/packages/app/styles/LogTable.module.scss b/packages/app/styles/LogTable.module.scss index c7348ec44..177b7d60a 100644 --- a/packages/app/styles/LogTable.module.scss +++ b/packages/app/styles/LogTable.module.scss @@ -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; +} diff --git a/packages/app/styles/SearchPage.module.scss b/packages/app/styles/SearchPage.module.scss index 702a4a0a1..3ae43cf25 100644 --- a/packages/app/styles/SearchPage.module.scss +++ b/packages/app/styles/SearchPage.module.scss @@ -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); +}