diff --git a/frontend/src/container/SpanDetailsDrawer/Attributes/AttributeActions.tsx b/frontend/src/container/SpanDetailsDrawer/Attributes/AttributeActions.tsx new file mode 100644 index 000000000000..e17391069668 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/Attributes/AttributeActions.tsx @@ -0,0 +1,160 @@ +import { Button, Popover, Spin, Tooltip } from 'antd'; +import GroupByIcon from 'assets/CustomIcons/GroupByIcon'; +import { OPERATORS } from 'constants/antlrQueryConstants'; +import { useTraceActions } from 'hooks/trace/useTraceActions'; +import { ArrowDownToDot, ArrowUpFromDot, Copy, Ellipsis } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; + +interface AttributeRecord { + field: string; + value: string; +} + +interface AttributeActionsProps { + record: AttributeRecord; +} + +export default function AttributeActions({ + record, +}: AttributeActionsProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false); + const [isFilterInLoading, setIsFilterInLoading] = useState(false); + const [isFilterOutLoading, setIsFilterOutLoading] = useState(false); + + const { + onAddToQuery, + onGroupByAttribute, + onCopyFieldName, + onCopyFieldValue, + } = useTraceActions(); + + const textToCopy = useMemo(() => { + const str = record.value == null ? '' : String(record.value); + // Remove surrounding double-quotes only (e.g., JSON-encoded string values) + return str.replace(/^"|"$/g, ''); + }, [record.value]); + + const handleFilterIn = useCallback(async (): Promise => { + if (!onAddToQuery || isFilterInLoading) return; + setIsFilterInLoading(true); + try { + await Promise.resolve( + onAddToQuery(record.field, record.value, OPERATORS['=']), + ); + } finally { + setIsFilterInLoading(false); + } + }, [onAddToQuery, record.field, record.value, isFilterInLoading]); + + const handleFilterOut = useCallback(async (): Promise => { + if (!onAddToQuery || isFilterOutLoading) return; + setIsFilterOutLoading(true); + try { + await Promise.resolve( + onAddToQuery(record.field, record.value, OPERATORS['!=']), + ); + } finally { + setIsFilterOutLoading(false); + } + }, [onAddToQuery, record.field, record.value, isFilterOutLoading]); + + const handleGroupBy = useCallback((): void => { + if (onGroupByAttribute) { + onGroupByAttribute(record.field); + } + setIsOpen(false); + }, [onGroupByAttribute, record.field]); + + const handleCopyFieldName = useCallback((): void => { + if (onCopyFieldName) { + onCopyFieldName(record.field); + } + setIsOpen(false); + }, [onCopyFieldName, record.field]); + + const handleCopyFieldValue = useCallback((): void => { + if (onCopyFieldValue) { + onCopyFieldValue(textToCopy); + } + setIsOpen(false); + }, [onCopyFieldValue, textToCopy]); + + const moreActionsContent = ( +
+ + + +
+ ); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss index 5c686e5aec0f..abd920e82bb6 100644 --- a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss +++ b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.styles.scss @@ -24,6 +24,13 @@ flex-direction: column; gap: 8px; justify-content: flex-start; + position: relative; + + &:hover { + .action-btn { + display: flex; + } + } .item-key { color: var(--bg-vanilla-100); @@ -40,11 +47,12 @@ padding: 2px 8px; align-items: center; width: fit-content; - max-width: 100%; + max-width: calc(100% - 120px); /* Reserve space for action buttons */ gap: 8px; border-radius: 50px; border: 1px solid var(--bg-slate-400); background: var(--bg-slate-500); + .item-value { color: var(--bg-vanilla-400); font-family: Inter; @@ -55,6 +63,35 @@ letter-spacing: 0.56px; } } + + .action-btn { + display: none; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + gap: 4px; + background: rgba(0, 0, 0, 0.8); + border-radius: 4px; + padding: 2px; + + .filter-btn { + display: flex; + align-items: center; + border: none; + box-shadow: none; + border-radius: 2px; + background: var(--bg-slate-400); + padding: 4px; + gap: 3px; + height: 24px; + width: 24px; + + &:hover { + background: var(--bg-slate-300); + } + } + } } } @@ -63,6 +100,36 @@ } } +.attribute-actions-menu { + display: flex; + flex-direction: column; + gap: 4px; + + .ant-btn { + text-align: left; + height: auto; + padding: 6px 12px; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + background-color: var(--bg-slate-400); + } + } + + .group-by-clause { + color: var(--text-primary); + } +} + +.attribute-actions-content { + .ant-popover-inner { + padding: 8px; + min-width: 160px; + } +} + .lightMode { .attributes-corner { .attributes-container { @@ -79,6 +146,18 @@ color: var(--bg-ink-400); } } + + .action-btn { + background: rgba(255, 255, 255, 0.9); + + .filter-btn { + background: var(--bg-vanilla-200); + + &:hover { + background: var(--bg-vanilla-100); + } + } + } } } @@ -86,4 +165,12 @@ border-top: 1px solid var(--bg-vanilla-300); } } + + .attribute-actions-menu { + .ant-btn { + &:hover { + background-color: var(--bg-vanilla-200); + } + } + } } diff --git a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx index 06c1e105909d..24475f69c7fe 100644 --- a/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx +++ b/frontend/src/container/SpanDetailsDrawer/Attributes/Attributes.tsx @@ -2,11 +2,13 @@ import './Attributes.styles.scss'; import { Input, Tooltip, Typography } from 'antd'; import cx from 'classnames'; +import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC'; import { flattenObject } from 'container/LogDetailedView/utils'; import { useMemo, useState } from 'react'; import { Span } from 'types/api/trace/getTraceV2'; import NoData from '../NoData/NoData'; +import AttributeActions from './AttributeActions'; interface IAttributesProps { span: Span; @@ -53,10 +55,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
- - {item.value} - + + + {item.value} + + +
))} diff --git a/frontend/src/hooks/trace/useTraceActions.ts b/frontend/src/hooks/trace/useTraceActions.ts new file mode 100644 index 000000000000..eac6489c027c --- /dev/null +++ b/frontend/src/hooks/trace/useTraceActions.ts @@ -0,0 +1,193 @@ +import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys'; +import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { QueryBuilderKeys } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useNotifications } from 'hooks/useNotifications'; +import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; +import { useCallback } from 'react'; +import { useQueryClient } from 'react-query'; +import { useCopyToClipboard } from 'react-use'; +import { + BaseAutocompleteData, + DataTypes, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +export interface UseTraceActionsReturn { + onAddToQuery: ( + fieldKey: string, + fieldValue: string, + operator: string, + ) => Promise; + onGroupByAttribute: (fieldKey: string) => Promise; + onCopyFieldName: (fieldName: string) => void; + onCopyFieldValue: (fieldValue: string) => void; +} + +export const useTraceActions = (): UseTraceActionsReturn => { + const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder(); + const queryClient = useQueryClient(); + const { notifications } = useNotifications(); + const [, setCopy] = useCopyToClipboard(); + + const removeExistingFieldFilters = useCallback( + (filters: TagFilterItem[], fieldKey: BaseAutocompleteData): TagFilterItem[] => + filters.filter((filter: TagFilterItem) => filter.key?.key !== fieldKey.key), + [], + ); + + const getAutocompleteKey = useCallback( + async (fieldKey: string): Promise => { + const keysAutocompleteResponse = await queryClient.fetchQuery( + [QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey], + async () => + getAggregateKeys({ + searchText: fieldKey, + aggregateOperator: + currentQuery.builder.queryData[0].aggregateOperator || '', + dataSource: DataSource.TRACES, + aggregateAttribute: + currentQuery.builder.queryData[0].aggregateAttribute?.key || '', + }), + ); + + const keysAutocomplete: BaseAutocompleteData[] = + keysAutocompleteResponse.payload?.attributeKeys || []; + + return chooseAutocompleteFromCustomValue( + keysAutocomplete, + fieldKey, + false, + DataTypes.String, + ); + }, + [queryClient, currentQuery.builder.queryData], + ); + + const onAddToQuery = useCallback( + async ( + fieldKey: string, + fieldValue: string, + operator: string, + ): Promise => { + try { + const existAutocompleteKey = await getAutocompleteKey(fieldKey); + const currentOperator = getOperatorValue(operator); + + const nextQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item) => { + // Get existing filters and remove any for the same field + const currentFilters = item.filters?.items || []; + const cleanedFilters = removeExistingFieldFilters( + currentFilters, + existAutocompleteKey, + ); + + // Add the new filter to the cleaned list + const newFilters = [ + ...cleanedFilters, + { + id: uuid(), + key: existAutocompleteKey, + op: currentOperator, + value: fieldValue, + }, + ]; + + const convertedFilter = convertFiltersToExpressionWithExistingQuery( + { + items: newFilters, + op: item.filters?.op || 'AND', + }, + item.filter?.expression || '', + ); + + return { + ...item, + dataSource: DataSource.TRACES, + filters: convertedFilter.filters, + filter: convertedFilter.filter, + }; + }), + }, + }; + + redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER); + } catch { + notifications.error({ message: SOMETHING_WENT_WRONG }); + } + }, + [ + currentQuery, + notifications, + getAutocompleteKey, + redirectWithQueryBuilderData, + removeExistingFieldFilters, + ], + ); + + const onGroupByAttribute = useCallback( + async (fieldKey: string): Promise => { + try { + const existAutocompleteKey = await getAutocompleteKey(fieldKey); + + const nextQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item) => ({ + ...item, + dataSource: DataSource.TRACES, + groupBy: [...item.groupBy, existAutocompleteKey], + })), + }, + }; + + redirectWithQueryBuilderData(nextQuery, {}, ROUTES.TRACES_EXPLORER); + } catch { + notifications.error({ message: SOMETHING_WENT_WRONG }); + } + }, + [ + currentQuery, + notifications, + getAutocompleteKey, + redirectWithQueryBuilderData, + ], + ); + + const onCopyFieldName = useCallback( + (fieldName: string): void => { + setCopy(fieldName); + notifications.success({ + message: 'Field name copied to clipboard', + }); + }, + [setCopy, notifications], + ); + + const onCopyFieldValue = useCallback( + (fieldValue: string): void => { + setCopy(fieldValue); + notifications.success({ + message: 'Field value copied to clipboard', + }); + }, + [setCopy, notifications], + ); + + return { + onAddToQuery, + onGroupByAttribute, + onCopyFieldName, + onCopyFieldValue, + }; +};