mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-04 09:20:34 +01:00
Compare commits
9 Commits
feat/dropd
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33520c41c8 | ||
|
|
b994d6dd8e | ||
|
|
5e231e799e | ||
|
|
5f4a79c201 | ||
|
|
8edf375019 | ||
|
|
0d1fd6d0bd | ||
|
|
fefd0effef | ||
|
|
36a137be4d | ||
|
|
68dc7e426a |
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -122,7 +122,7 @@ function PrettyView({
|
||||
: String(context.fieldValue);
|
||||
setCopy(text);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: true,
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user