feat: trace detail page actionables (#8761)

* feat: add table view with actionables to span details drawer attributes

* feat: span actions functionality

* refactor: overall improvements

* feat: revert to key-value pair UI with hover actions

* feat: add support for copying trace attribute value on click

* refactor: prevent prop drilling and access return values from useTraceActions in AttributeActions

* refactor: integrate filter conversion logic into useTraceActions for improved query handling
This commit is contained in:
Shaheer Kochai 2025-08-19 11:20:28 +04:30 committed by GitHub
parent 03359a40a2
commit fdcad997f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 449 additions and 4 deletions

View File

@ -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<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(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<void> => {
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<void> => {
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 = (
<div className="attribute-actions-menu">
<Button
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupBy}
block
>
Group By Attribute
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</div>
);
return (
<div className="action-btn">
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter for value"
disabled={isFilterInLoading}
icon={
isFilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterIn}
/>
</Tooltip>
<Tooltip title="Filter out value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter out value"
disabled={isFilterOutLoading}
icon={
isFilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterOut}
/>
</Tooltip>
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={moreActionsContent}
rootClassName="attribute-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
</div>
);
}

View File

@ -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);
}
}
}
}

View File

@ -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 {
</Typography.Text>
<div className="value-wrapper">
<Tooltip title={item.value}>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>
</CopyClipboardHOC>
</Tooltip>
<AttributeActions record={item} />
</div>
</div>
))}

View File

@ -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<void>;
onGroupByAttribute: (fieldKey: string) => Promise<void>;
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<BaseAutocompleteData> => {
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<void> => {
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<void> => {
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,
};
};