From b6df9945f8bf6aaf93c53b48b17bb07b543367b2 Mon Sep 17 00:00:00 2001 From: "jie.yang" Date: Thu, 11 Sep 2025 20:46:54 +0800 Subject: [PATCH 1/2] enhancement row actions. --- .../app/src/components/DBRowJsonViewer.tsx | 251 +++++++++++++++--- 1 file changed, 216 insertions(+), 35 deletions(-) diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index 825b8b71d..7565ccc4a 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -1,10 +1,11 @@ import { useCallback, useContext, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import router from 'next/router'; import { useAtom, useAtomValue } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import get from 'lodash/get'; +import lucene from '@hyperdx/lucene'; import { - ActionIcon, Box, Button, Group, @@ -118,6 +119,97 @@ function HyperJsonMenu() { ); } +// if keep is true, remove node which matched the match condition. +// if value is '', match condition is key equal. +// if value is not '', match condition is key and value equal. +function rangeNodesWithKey( + ast: any, + key: string, + value: string, + keep: boolean, +): { result: any; modified: boolean } { + if (!ast) return { result: null, modified: false }; + + if (ast.term) { + let matched = false; + if (ast.field === key || ast.field === `-${key}`) { + if (value !== '') { + if (ast.term === value) { + matched = true; + } + } else { + matched = true; + } + } + if ((matched && keep) || (!matched && !keep)) { + return { result: ast, modified: false }; + } else { + return { result: null, modified: true }; + } + } + + const leftResult = rangeNodesWithKey(ast.left, key, value, keep); + const rightResult = rangeNodesWithKey(ast.right, key, value, keep); + const left = leftResult.result; + const right = rightResult.result; + const modified = leftResult.modified || rightResult.modified; + + if (!left && !right) { + return { result: null, modified: modified }; + } + + if (!left && right) { + return { result: right, modified: modified }; + } + + if (left && !right) { + return { result: left, modified: modified }; + } + + return { + result: { + ...ast, + left, + right, + }, + modified, + }; +} + +// removeLuceneField remove field in a lucene search query. +// modified meaning if it is removed success. +// example: +// removeLuceneField('(ServiceName:a OR ServiceName:b) AND SeverityText:ERROR', 'ServiceName', 'a') +// ServiceName:b AND SeverityText:ERROR +export function removeLuceneField( + query: string, + key: string, + value: string, +): { result: string; modified: boolean } { + if (typeof query !== 'string') return { result: query, modified: false }; + + try { + // delete matched node + const ast = lucene.parse(query); + const { result: modifiedAst, modified } = rangeNodesWithKey( + ast, + key, + value, + false, + ); + + if (!modifiedAst) { + return { result: '', modified: modified }; + } + + const modifiedString = lucene.toString(modifiedAst); + return { result: modifiedString, modified }; + } catch (error) { + console.warn('Failed to parse Lucene query', error); + + return { result: query, modified: false }; + } +} export function DBRowJsonViewer({ data, jsonColumns = [], @@ -151,6 +243,8 @@ export function DBRowJsonViewer({ return filterObjectRecursively(data, debouncedFilter); }, [data, debouncedFilter]); + const searchParams = useSearchParams(); + const getLineActions = useCallback( ({ keyPath, value }) => { const actions: LineAction[] = []; @@ -158,60 +252,137 @@ export function DBRowJsonViewer({ const isJsonColumn = keyPath.length > 0 && jsonColumns?.includes(keyPath[0]); - // Add to Filters action (strings only) - // FIXME: TOTAL HACK To disallow adding timestamp to filters - if ( - onPropertyAddClick != null && - typeof value === 'string' && - value && - fieldPath != 'Timestamp' && - fieldPath != 'TimestampTime' - ) { + let where = searchParams.get('where') || ''; + let whereLanguage = searchParams.get('whereLanguage'); + if (whereLanguage == '') { + whereLanguage = 'lucene'; + } + + let luceneFieldPath = ''; + if (whereLanguage === 'lucene') { + luceneFieldPath = keyPath.join('.'); + } + + let removedFilterWhere = ''; // filter which already removed value. + let hadFilter = false; + if (where !== '') { + // if it is lucene, we support remove-filter. + if (whereLanguage === 'lucene') { + const { result, modified } = removeLuceneField( + where, + luceneFieldPath, + value, + ); + removedFilterWhere = result; + hadFilter = modified; + where += ' '; + } else { + where += ' AND '; + } + } + + if (generateSearchUrl && typeof value !== 'object' && hadFilter) { actions.push({ - key: 'add-to-search', + key: 'remove-filter', label: ( <> - - Add to Filters + + Remove Filter ), - title: 'Add to Filters', onClick: () => { - onPropertyAddClick( - isJsonColumn ? `toString(${fieldPath})` : fieldPath, - value, + router.push( + generateSearchUrl({ + where: removedFilterWhere, + whereLanguage: whereLanguage as 'sql' | 'lucene', + }), ); - notifications.show({ - color: 'green', - message: `Added "${fieldPath} = ${value}" to filters`, - }); }, }); } - if (generateSearchUrl && typeof value !== 'object') { + if (generateSearchUrl && typeof value !== 'object' && !hadFilter) { actions.push({ - key: 'search', + key: 'filter', label: ( <> - Search + Filter ), - title: 'Search for this value only', + title: 'Add to Filters', onClick: () => { - let defaultWhere = `${fieldPath} = ${ - typeof value === 'string' ? `'${value}'` : value - }`; + if (whereLanguage === 'lucene') { + where += `${luceneFieldPath}:"${value}"`; + } else { + where += `${fieldPath} = ${ + typeof value === 'string' ? `'${value}'` : value + }`; + } - // FIXME: TOTAL HACK - if (fieldPath == 'Timestamp' || fieldPath == 'TimestampTime') { - defaultWhere = `${fieldPath} = parseDateTime64BestEffort('${value}', 9)`; + router.push( + generateSearchUrl({ + where: where, + whereLanguage: whereLanguage as 'sql' | 'lucene', + }), + ); + }, + }); + } + + if (generateSearchUrl && typeof value !== 'object' && !hadFilter) { + actions.push({ + key: 'exclude', + label: ( + <> + + Exclude + + ), + title: 'Exclude from Filters', + onClick: () => { + if (whereLanguage === 'lucene') { + where += `-${luceneFieldPath}:"${value}"`; + } else { + where += `${fieldPath} != ${ + typeof value === 'string' ? `'${value}'` : value + }`; } + router.push( generateSearchUrl({ - where: defaultWhere, - whereLanguage: 'sql', + where: where, + whereLanguage: whereLanguage as 'sql' | 'lucene', + }), + ); + }, + }); + } + + if (generateSearchUrl && typeof value !== 'object' && !hadFilter) { + actions.push({ + key: 'replace-filter', + label: ( + <> + + Replace Filter + + ), + title: 'Search for this value only', + onClick: () => { + where = ''; + if (whereLanguage === 'lucene') { + where = `${luceneFieldPath}:"${value}"`; + } else { + where = `${fieldPath} = ${ + typeof value === 'string' ? `'${value}'` : value + }`; + } + + router.push( + generateSearchUrl({ + where: where, + whereLanguage: whereLanguage as 'sql' | 'lucene', }), ); }, @@ -282,13 +453,23 @@ export function DBRowJsonViewer({ if (typeof value === 'object') { actions.push({ key: 'copy-object', - label: 'Copy Object', + label: ( + <> + + Copy Object + + ), onClick: handleCopyObject, }); } else { actions.push({ key: 'copy-value', - label: 'Copy Value', + label: ( + <> + + Copy Value + + ), onClick: () => { window.navigator.clipboard.writeText( typeof value === 'string' From e0ffecc98e52c3a673d8d75bac263874a1473366 Mon Sep 17 00:00:00 2001 From: "jie.yang" Date: Wed, 17 Sep 2025 16:17:42 +0800 Subject: [PATCH 2/2] fix update where failed if removedFilterWhere is '' --- packages/app/src/components/DBRowJsonViewer.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index 7565ccc4a..b64d21b86 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -253,6 +253,7 @@ export function DBRowJsonViewer({ keyPath.length > 0 && jsonColumns?.includes(keyPath[0]); let where = searchParams.get('where') || ''; + where = where.trim(); let whereLanguage = searchParams.get('whereLanguage'); if (whereLanguage == '') { whereLanguage = 'lucene'; @@ -281,6 +282,12 @@ export function DBRowJsonViewer({ } } + // if removedFilterWhere is '', use ' ' + // cause generateSearchUrl would use searchedConfig.where if where is '' + if (removedFilterWhere === '') { + removedFilterWhere = ' '; + } + if (generateSearchUrl && typeof value !== 'object' && hadFilter) { actions.push({ key: 'remove-filter',