Compare commits

...

9 Commits

Author SHA1 Message Date
aks07
33520c41c8 fix: scroll to span in frontend mode 2026-05-04 11:32:13 +05:30
aks07
b994d6dd8e feat: fix color 2026-04-25 16:22:48 +05:30
aks07
5e231e799e feat: filter toggle added 2026-04-25 11:26:59 +05:30
aks07
5f4a79c201 feat: filters init 2026-04-23 18:58:51 +05:30
aks07
8edf375019 feat: floating fields set 2026-04-22 21:11:33 +05:30
aks07
0d1fd6d0bd feat: minor changes 2026-04-22 18:47:47 +05:30
aks07
fefd0effef feat: add trace details header styles 2026-04-22 17:59:48 +05:30
aks07
36a137be4d feat: add trace details header styles 2026-04-22 15:49:17 +05:30
aks07
68dc7e426a feat: add trace details header 2026-04-22 15:32:01 +05:30
20 changed files with 1265 additions and 92 deletions

View File

@@ -38,5 +38,6 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
TRACE_DETAILS_SHOW_TRACE_OVERVIEW = 'TRACE_DETAILS_SHOW_TRACE_OVERVIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
}

View File

@@ -29,7 +29,7 @@ export function EventTooltipContent({
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
{toFixed(time, 2)} {timeUnitName} since span start
</div>
{Object.keys(attributeMap).length > 0 && (
<>

View File

@@ -1,3 +1,8 @@
.trace-details-header-wrapper {
flex-shrink: 0;
position: relative;
}
.trace-details-header {
display: flex;
align-items: center;
@@ -16,13 +21,48 @@
&.trace-v3-filter-row {
padding: 0;
}
max-width: 850px;
flex: 1;
min-width: 0;
&:not(&--expanded) {
margin-left: auto;
}
&--expanded {
max-width: none;
flex: 1;
}
}
&__old-view-btn {
margin-left: auto;
flex-shrink: 0;
}
&__sub-header {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 16px 8px;
font-size: 13px;
color: var(--l2-foreground);
}
&__sub-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
&__separator {
color: var(--l2-foreground);
opacity: 0.5;
}
&__entry-point-badge {
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
font-size: 12px;
}
}

View File

@@ -1,13 +1,18 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import history from 'lib/history';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, CalendarClock, Server, Timer } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import TraceOptionsMenu from './TraceOptionsMenu';
import './TraceDetailsHeader.styles.scss';
@@ -17,18 +22,34 @@ interface FilterMetadata {
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
noData?: boolean;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
noData,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(
() =>
localStorage.getItem(LOCALSTORAGE.TRACE_DETAILS_SHOW_TRACE_OVERVIEW) ===
'true',
);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const handleSwitchToOldView = useCallback((): void => {
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
@@ -48,43 +69,127 @@ function TraceDetailsHeader({
}
}, []);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => {
const next = !prev;
localStorage.setItem(
LOCALSTORAGE.TRACE_DETAILS_SHOW_TRACE_OVERVIEW,
String(next),
);
return next;
});
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
durationMs,
);
return (
<div className="trace-details-header">
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
{!noData && (
<>
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
<div className="trace-details-header-wrapper">
<div className="trace-details-header">
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="md"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
</>
)}
{isDataLoaded && (
<>
<div
className={`trace-details-header__filter${
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
}`}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
/>
</>
)}
</>
)}
</div>
{showTraceDetails && traceMetadata && (
<div className="trace-details-header__sub-header">
<span className="trace-details-header__sub-item">
<Server size={13} />
{traceMetadata.rootServiceName}
<span className="trace-details-header__separator"></span>
<span className="trace-details-header__entry-point-badge">
{traceMetadata.rootServiceEntryPoint}
</span>
</span>
<span className="trace-details-header__sub-item">
<Timer size={13} />
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
</span>
<span className="trace-details-header__sub-item">
<CalendarClock size={13} />
{dayjs(traceMetadata.startTimestampMillis).format('D MMM YYYY, HH:mm:ss')}
</span>
{traceMetadata.rootSpanStatusCode && (
<HttpStatusBadge statusCode={traceMetadata.rootSpanStatusCode} />
)}
</div>
)}
{/* {isPreviewFieldsOpen && (
<FloatingPanel
isOpen
width={350}
height={window.innerHeight - 100}
defaultPosition={{
x: window.innerWidth - 350 - 100,
y: 50,
}}
enableResizing={false}
>
<FieldsSettings
title="Preview fields"
fields={previewFields}
onFieldsChange={setPreviewFields}
onClose={(): void => setIsPreviewFieldsOpen(false)}
dataSource={DataSource.TRACES}
/>
</FloatingPanel>
)} */}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui';
import { Button, Dropdown } from '@signozhq/ui';
import { Ellipsis } from 'lucide-react';
interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
}
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
}: TraceOptionsMenuProps): JSX.Element {
const menuItems: MenuItem[] = useMemo(
() => [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
// {
// key: 'preview-fields',
// label: 'Preview fields',
// onClick: (): void => setIsPreviewFieldsOpen(!isPreviewFieldsOpen),
// },
],
[showTraceDetails, onToggleTraceDetails],
);
return (
<Dropdown menu={{ items: menuItems }}>
<Button variant="solid" color="secondary" size="sm">
<Ellipsis size={14} />
</Button>
</Dropdown>
);
}
export default TraceOptionsMenu;

View File

@@ -229,10 +229,7 @@ export function useFlamegraphDraw(
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
const filteredSpanIdsSet = useMemo(
() =>
isFilterActive && filteredSpanIds && filteredSpanIds.length > 0
? new Set(filteredSpanIds)
: null,
() => (isFilterActive && filteredSpanIds ? new Set(filteredSpanIds) : null),
[filteredSpanIds, isFilterActive],
);

View File

@@ -211,7 +211,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Alpha is applied to bar + events only; label is drawn after restoring alpha to 1
// so text stays readable against the faded bar.
if (shouldDim) {
ctx.globalAlpha = 0.4;
ctx.globalAlpha = 0.15;
}
ctx.beginPath();

View File

@@ -3,10 +3,116 @@
align-items: center;
gap: 12px;
&.expanded {
flex: 1;
}
.filter-search-container {
flex: 1;
min-width: 0;
}
.query-builder-search-v2 {
width: 100%;
}
// --- Collapsed pill ---
.filter-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
cursor: pointer;
max-width: 220px;
min-width: 120px;
height: 32px;
background: var(--l1-background);
&:hover {
border-color: var(--primary-background-hover);
}
&__text {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
flex-shrink: 0;
}
}
// --- Collapsed pill popover ---
.filter-pill-popover {
max-width: 400px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.ant-typography {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
}
&__expression {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-all;
padding: 6px 8px;
background: var(--l2-background);
border-radius: 4px;
}
}
// --- ToggleGroup override: size to content, don't stretch items ---
[class*='toggle-group'] {
flex-shrink: 0;
[class*='toggle-group-item'] {
flex: 0 0 auto;
}
}
// --- Collapse button ---
.filter-collapse-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
// --- Highlight errors toggle ---
.highlight-errors-toggle {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
.ant-typography {
color: var(--l2-foreground);
font-size: 12px;
}
}
// --- Prev/next navigation ---
.pre-next-toggle {
display: flex;
flex-shrink: 0;

View File

@@ -1,19 +1,31 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, Tooltip, Typography } from 'antd';
import { Switch, ToggleGroup, ToggleGroupItem } from '@signozhq/ui';
import { toast } from '@signozhq/ui';
import { Button, Popover, Spin, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ChevronDown, ChevronUp, Copy, Search, X } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import { useHighlightErrors } from './hooks/useHighlightErrors';
import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import './Filters.styles.scss';
@@ -44,6 +56,7 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
},
],
},
selectColumns: [],
},
],
},
@@ -55,31 +68,92 @@ function Filters({
endTime,
traceID,
onFilteredSpansChange = (): void => {},
isExpanded,
onExpand,
onCollapse,
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
isExpanded: boolean;
onExpand: () => void;
onCollapse: () => void;
}): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [expression, setExpression] = useState<string>('');
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const expressionRef = useRef<string>('');
const containerRef = useRef<HTMLDivElement>(null);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
const runQuery = useCallback(
(value: string): void => {
const items = convertExpressionToFilters(value);
setFilters({ items, op: 'AND' });
// Clear results when expression produces no filters
if (items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
// onChange fires on every keystroke — only store the expression, don't trigger API
const handleExpressionChange = useCallback(
(value: string): void => {
setExpression(value);
expressionRef.current = value;
// Clear results when expression is emptied
if (!value.trim()) {
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
},
[onFilteredSpansChange],
);
// onRun fires on Ctrl+Enter
const handleRunQuery = useCallback(
(value: string): void => {
runQuery(value);
},
[runQuery],
);
// Run query on blur (click outside the filter input)
const handleBlur = useCallback((): void => {
runQuery(expressionRef.current);
}, [runQuery]);
// Expression-based filter hooks
const filterProps = {
expression,
filters,
setExpression,
expressionRef,
runQuery,
};
const {
isHighlightErrors,
handleToggle: handleToggleHighlightErrors,
} = useHighlightErrors(filterProps);
const {
selectedCategory,
categories,
handleCategoryChange,
} = useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
@@ -110,14 +184,14 @@ function Filters({
tableParams: {
pagination: {
offset: 0,
limit: 200,
limit: 10000,
},
selectColumns: [
{
key: 'name',
key: 'spanID',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
id: 'spanId--string--tag--true',
isIndexed: false,
},
],
@@ -150,18 +224,117 @@ function Filters({
},
);
return (
<div className="trace-v3-filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
const highlightErrorsToggle = (
<div className="highlight-errors-toggle">
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"
value={isHighlightErrors}
onChange={handleToggleHighlightErrors}
/>
</div>
);
const statusIndicators = (
<>
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</>
);
// --- COLLAPSED VIEW ---
if (!isExpanded) {
return (
<div className="trace-v3-filter-row collapsed">
<Popover
content={
expression ? (
<div className="filter-pill-popover">
<div className="filter-pill-popover__header">
<Typography.Text>Search query</Typography.Text>
<Button
type="text"
size="small"
icon={<Copy size={12} />}
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
/>
</div>
<div className="filter-pill-popover__expression">{expression}</div>
</div>
) : null
}
trigger="hover"
placement="bottomLeft"
arrow={false}
overlayStyle={{ maxWidth: 400 }}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="filter-pill" onClick={onExpand}>
<Search size={12} />
<span className="filter-pill__text">{expression || 'Search...'}</span>
{expression && <span className="filter-pill__indicator" />}
</div>
</Popover>
{highlightErrorsToggle}
{statusIndicators}
</div>
);
}
// --- EXPANDED VIEW ---
return (
<div className="trace-v3-filter-row expanded">
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="filter-search-container"
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
handleBlur();
}
}}
>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
@@ -187,15 +360,14 @@ function Filters({
/>
</div>
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
<Button
type="text"
icon={<X size={14} />}
onClick={onCollapse}
className="filter-collapse-btn"
/>
{highlightErrorsToggle}
{statusIndicators}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { MutableRefObject } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
/**
* Shared props for expression-based filter hooks.
* Each hook reads the current expression + derived filters,
* and manipulates the expression via remove/add pattern.
*/
export interface ExpressionFilterProps {
expression: string;
filters: TagFilter;
setExpression: (expr: string) => void;
expressionRef: MutableRefObject<string>;
runQuery: (expr: string) => void;
}
/**
* Helper: update expression state, ref, and trigger query.
*/
export function applyExpression(
newExpression: string,
props: Pick<
ExpressionFilterProps,
'setExpression' | 'expressionRef' | 'runQuery'
>,
): void {
props.setExpression(newExpression);
props.expressionRef.current = newExpression;
props.runQuery(newExpression);
}

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
interface UseHighlightErrorsReturn {
isHighlightErrors: boolean;
handleToggle: (checked: boolean) => void;
}
const ERROR_KEY = 'has_error';
export function useHighlightErrors(
props: ExpressionFilterProps,
): UseHighlightErrorsReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive from filters (only updates after runQuery, not on every keystroke)
const isHighlightErrors = useMemo(
() =>
filters.items.some(
(item) =>
item.key?.key === ERROR_KEY &&
(item.value === true || item.value === 'true'),
),
[filters],
);
const handleToggle = useCallback(
(checked: boolean): void => {
// Always remove existing has_error first (whatever its value)
let newExpr = removeKeysFromExpression(expression, [ERROR_KEY]);
// Add back if turning ON
if (checked) {
newExpr = newExpr.trim()
? `${newExpr.trim()} AND has_error = true`
: `has_error = true`;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return { isHighlightErrors, handleToggle };
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
export type SpanCategory = 'All' | 'Database' | 'Functions' | 'HTTP' | 'Jobs';
export const SPAN_CATEGORIES: readonly SpanCategory[] = [
'All',
'Database',
'Functions',
'HTTP',
'Jobs',
];
// Map each category to the attribute key it filters on
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system',
HTTP: 'http.method',
Functions: 'kind_string',
Jobs: 'messaging.system',
};
// All category keys — used for bulk removal when switching categories
const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
// The expression clause to add for each category
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: "db.system != ''",
HTTP: "http.method != ''",
Functions: "kind_string = 'Internal'",
Jobs: "messaging.system != ''",
};
interface UseSpanCategoryFilterReturn {
selectedCategory: SpanCategory;
categories: readonly SpanCategory[];
handleCategoryChange: (category: SpanCategory) => void;
}
export function useSpanCategoryFilter(
props: ExpressionFilterProps,
): UseSpanCategoryFilterReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive active category from filters (only updates after runQuery)
const selectedCategory = useMemo((): SpanCategory => {
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
if (filters.items.some((item) => item.key?.key === key)) {
return category as SpanCategory;
}
}
return 'All';
}, [filters]);
const handleCategoryChange = useCallback(
(category: SpanCategory): void => {
// Remove ALL category keys first
let newExpr = removeKeysFromExpression(expression, ALL_CATEGORY_KEYS);
// Add the selected category clause (unless "All")
if (category !== 'All') {
const clause = CATEGORY_EXPRESSIONS[category];
newExpr = newExpr.trim() ? `${newExpr.trim()} AND ${clause}` : clause;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return {
selectedCategory,
categories: SPAN_CATEGORIES,
handleCategoryChange,
};
}

View File

@@ -244,7 +244,7 @@
}
&.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
}
}
@@ -540,7 +540,7 @@
}
.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
.highlighted-span {
opacity: 1;

View File

@@ -130,7 +130,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
});
// css config
const CONNECTOR_WIDTH = 20;
const CONNECTOR_WIDTH = 30;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface SpanStateClasses {
@@ -534,14 +534,15 @@ function Success(props: ISuccessProps): JSX.Element {
}
return next;
});
} else {
// Backend mode: trigger API call (current behavior)
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
}
// Backend mode: trigger API call (current behavior)
// keeping this for both mode to support scroll to view to function well.
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
},
[isFullDataLoaded, setLocalUncollapsedNodes, setInterestedSpanId],
);
@@ -619,7 +620,7 @@ function Success(props: ISuccessProps): JSX.Element {
});
}
},
[spans, setInterestedSpanId],
[spans, setInterestedSpanId, isFullDataLoaded],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(

View File

@@ -0,0 +1,138 @@
import { useMemo } from 'react';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from 'lucide-react';
function SortableField({
field,
onRemove,
allowDrag,
allowRemove,
}: {
field: string;
onRemove: (field: string) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: field });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={14} />}
<span className="fs-field-key">{field}</span>
</div>
{allowRemove && (
<Button
className="remove-field-btn periscope-btn"
size="small"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
)}
</div>
);
}
interface AddedFieldsProps {
inputValue: string;
fields: string[];
onFieldsChange: (fields: string[]) => void;
}
function AddedFields({
inputValue,
fields,
onFieldsChange,
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f === active.id);
const newIndex = fields.findIndex((f) => f === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
const filteredFields = useMemo(
() =>
fields.filter((f) => f.toLowerCase().includes(inputValue.toLowerCase())),
[fields, inputValue],
);
const handleRemove = (field: string): void => {
onFieldsChange(fields.filter((f) => f !== field));
};
const allowDrag = inputValue.length === 0;
return (
<div className="fs-section fs-added">
<div className="fs-section-header">ADDED FIELDS</div>
<div className="fs-added-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{filteredFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
<SortableContext
items={fields}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
allowRemove={fields.length > 1}
/>
))}
</SortableContext>
)}
</DndContext>
</OverlayScrollbar>
</div>
</div>
);
}
export default AddedFields;

View File

@@ -0,0 +1,171 @@
.fields-settings {
display: flex;
flex-direction: column;
height: 100%;
background: var(--l1-background);
color: var(--l1-foreground);
overflow: hidden;
.fs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
.fs-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
}
.fs-close-icon {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
.fs-search {
.ant-input {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
}
}
.fs-section {
display: flex;
flex-direction: column;
&.fs-added {
max-height: 40%;
border-bottom: 1px solid var(--l1-border);
}
&.fs-other {
flex: 1;
min-height: 0;
}
}
.fs-section-header {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
.fs-added-list {
overflow: hidden;
}
.fs-other-list {
flex: 1;
min-height: 0;
overflow: hidden;
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.fs-no-values {
padding: 8px;
text-align: center;
color: var(--l2-foreground);
font-size: 12px;
}
.fs-limit-hint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
}
.fs-field-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.fs-field-key {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 6px 12px;
}
&.other-field-item {
height: 32px;
}
.remove-field-btn,
.add-field-btn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
&:hover {
background-color: var(--l2-background);
.remove-field-btn,
.add-field-btn {
opacity: 1;
}
}
}
.fs-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--l1-border);
button {
display: flex;
align-items: center;
justify-content: center;
width: 50%;
.ant-btn-icon {
margin: 3px !important;
}
}
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { CheckIcon, TableColumnsSplit, X, XIcon } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import './FieldsSettings.styles.scss';
const MAX_FIELDS_DEFAULT = 10;
interface FieldsSettingsProps {
title: string;
// State is just string[] of keys for now. Can be extended to objects later if needed.
fields: string[];
onFieldsChange: (fields: string[]) => void;
onClose: () => void;
dataSource: DataSource;
maxFields?: number;
}
function FieldsSettings({
title,
fields,
onFieldsChange,
onClose,
dataSource,
maxFields = MAX_FIELDS_DEFAULT,
}: FieldsSettingsProps): JSX.Element {
// Local draft state — changes here don't persist until Save
const [draftFields, setDraftFields] = useState<string[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleAdd = useCallback(
(key: string): void => {
if (draftFields.length >= maxFields) {
return;
}
if (draftFields.includes(key)) {
return;
}
setDraftFields((prev) => [...prev, key]);
},
[draftFields, maxFields],
);
const handleSave = useCallback((): void => {
onFieldsChange(draftFields);
onClose();
}, [draftFields, onFieldsChange, onClose]);
const handleDiscard = useCallback((): void => {
setDraftFields(fields);
}, [fields]);
const hasUnsavedChanges = useMemo(
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f === fields[i])
),
[draftFields, fields],
);
const isAtLimit = draftFields.length >= maxFields;
return (
<div className="fields-settings">
<div className="fs-header">
<div className="fs-title">
<TableColumnsSplit size={16} />
{title}
</div>
<X className="fs-close-icon" size={16} onClick={onClose} />
</div>
<section className="fs-search">
<Input
type="text"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
/>
</section>
<AddedFields
inputValue={inputValue}
fields={draftFields}
onFieldsChange={setDraftFields}
/>
<OtherFields
dataSource={dataSource}
debouncedInputValue={debouncedInputValue}
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
/>
{hasUnsavedChanges && (
<div className="fs-footer">
<Button
type="default"
onClick={handleDiscard}
icon={<XIcon width={14} height={14} />}
>
Discard
</Button>
<Button
type="primary"
onClick={handleSave}
icon={<CheckIcon width={14} height={14} />}
>
Save changes
</Button>
</div>
)}
</div>
);
}
export default FieldsSettings;

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource } from 'types/common/queryBuilder';
interface OtherFieldsProps {
dataSource: DataSource;
debouncedInputValue: string;
addedFields: string[];
onAdd: (key: string) => void;
isAtLimit: boolean;
}
function OtherFields({
dataSource,
debouncedInputValue,
addedFields,
onAdd,
isAtLimit,
}: OtherFieldsProps): JSX.Element {
// API call to get available attribute keys
const { data, isFetching } = useGetAggregateKeys(
{
searchText: debouncedInputValue,
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [
REACT_QUERY_KEY.GET_OTHER_FILTERS,
'preview-fields',
debouncedInputValue,
],
enabled: true,
},
);
// Filter out already-added fields, match on .key from API response objects
const otherFields = useMemo(() => {
const attributes = data?.payload?.attributeKeys || [];
const addedSet = new Set(addedFields);
return attributes.filter((attr) => !addedSet.has(attr.key));
}, [data, addedFields]);
if (isFetching) {
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
))}
</div>
</div>
);
}
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
<OverlayScrollbar>
<>
{otherFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
otherFields.map((attr) => (
<div key={attr.key} className="fs-field-item other-field-item">
<span className="fs-field-key">{attr.key}</span>
{!isAtLimit && (
<Button
className="add-field-btn periscope-btn"
size="small"
onClick={(): void => onAdd(attr.key)}
>
Add
</Button>
)}
</div>
))
)}
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>
</div>
);
}
export default OtherFields;

View File

@@ -14,6 +14,7 @@ import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
@@ -211,6 +212,23 @@ function TraceDetailsV3(): JSX.Element {
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -248,7 +266,8 @@ function TraceDetailsV3(): JSX.Element {
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
noData={showNoData}
isDataLoaded={!isFetchingTraceData && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
{showNoData ? (

View File

@@ -122,7 +122,7 @@ function PrettyView({
: String(context.fieldValue);
setCopy(text);
toast.success('Copied to clipboard', {
richColors: true,
richColors: false,
position: 'top-right',
});
},