diff --git a/package-lock.json b/package-lock.json index ba0c48168..940c4d395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18955,7 +18955,8 @@ "name": "@perses-dev/tracing-gantt-chart-plugin", "version": "0.12.1", "dependencies": { - "color-hash": "^2.0.2" + "color-hash": "^2.0.2", + "react-virtuoso": "^4.12.2" }, "devDependencies": { "react-router-dom": "^5 || ^6 || ^7" diff --git a/tracingganttchart/package.json b/tracingganttchart/package.json index 4bea875f9..59e496c2e 100644 --- a/tracingganttchart/package.json +++ b/tracingganttchart/package.json @@ -24,7 +24,8 @@ "module": "lib/index.js", "types": "lib/index.d.ts", "dependencies": { - "color-hash": "^2.0.2" + "color-hash": "^2.0.2", + "react-virtuoso": "^4.12.2" }, "peerDependencies": { "@emotion/react": "^11.7.1", diff --git a/tracingganttchart/src/TracingGanttChart/DetailPane/Attributes.tsx b/tracingganttchart/src/TracingGanttChart/DetailPane/Attributes.tsx index ba47ebb7a..bc8e7981d 100644 --- a/tracingganttchart/src/TracingGanttChart/DetailPane/Attributes.tsx +++ b/tracingganttchart/src/TracingGanttChart/DetailPane/Attributes.tsx @@ -168,7 +168,7 @@ export function AttributeItem(props: AttributeItemProps): ReactElement { ); } -function renderAttributeValue(value: otlpcommonv1.AnyValue): string { +export function renderAttributeValue(value: otlpcommonv1.AnyValue): string { if ('stringValue' in value) return value.stringValue || ''; if ('intValue' in value) return value.intValue; if ('doubleValue' in value) return String(value.doubleValue); @@ -177,5 +177,5 @@ function renderAttributeValue(value: otlpcommonv1.AnyValue): string { const values = value.arrayValue.values; return values && values.length > 0 ? values.map(renderAttributeValue).join(', ') : ''; } - return 'unknown'; + return ''; } diff --git a/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTable.tsx b/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTable.tsx index 9a708f53a..558ac9eef 100644 --- a/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTable.tsx +++ b/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTable.tsx @@ -11,12 +11,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Virtuoso, ListRange } from 'react-virtuoso'; -import { ReactElement, useMemo, useRef, useState } from 'react'; +import { Virtuoso, VirtuosoHandle, ListRange } from 'react-virtuoso'; +import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'; import { Box, useTheme } from '@mui/material'; import { Viewport } from '../utils'; import { CustomLinks, TracingGanttChartOptions } from '../../gantt-chart-model'; -import { Span, Trace } from '../trace'; +import { Span, Trace, forEachSpan } from '../trace'; import { useGanttTableContext } from './GanttTableProvider'; import { GanttTableRow } from './GanttTableRow'; import { GanttTableHeader } from './GanttTableHeader'; @@ -29,32 +29,72 @@ export interface GanttTableProps { viewport: Viewport; selectedSpan?: Span; onSpanClick: (span: Span) => void; + matchingSpanIds?: string[]; + focusedSpanId?: string; } export function GanttTable(props: GanttTableProps): ReactElement { - const { options, customLinks, trace, viewport, selectedSpan, onSpanClick } = props; - const { collapsedSpans, setVisibleSpans } = useGanttTableContext(); + const { options, customLinks, trace, viewport, selectedSpan, onSpanClick, matchingSpanIds, focusedSpanId } = props; + const { collapsedSpans, setCollapsedSpans, setVisibleSpans } = useGanttTableContext(); const [nameColumnWidth, setNameColumnWidth] = useState(0.25); const tableRef = useRef(null); + const virtuosoRef = useRef(null); const theme = useTheme(); + // Recursively flatten the span tree to a list of rows, hiding collapsed child spans. const rows = useMemo(() => { const rows: Span[] = []; - for (const rootSpan of trace.rootSpans) { - treeToRows(rows, rootSpan, collapsedSpans); - } + forEachSpan(trace.rootSpans, (span) => { + rows.push(span); + if (collapsedSpans.has(span.spanId)) { + return false; + } + }); return rows; }, [trace.rootSpans, collapsedSpans]); + const matchingSpanIdSet = useMemo(() => new Set(matchingSpanIds ?? []), [matchingSpanIds]); - const selectedSpanIndex = useMemo(() => { - if (!selectedSpan) return undefined; + // Auto-expand collapsed ancestors when focusing a search match + useEffect(() => { + if (!focusedSpanId) return; - for (let i = 0; i < rows.length; i++) { - if (rows[i]?.spanId === selectedSpan.spanId) { - return i; - } + const span = trace.spanById.get(focusedSpanId); + if (!span) return; + + const ancestorIds = new Set(); + let parent = span.parentSpan; + while (parent) { + ancestorIds.add(parent.spanId); + parent = parent.parentSpan; } - return undefined; + if (ancestorIds.size > 0) { + setCollapsedSpans((prev) => { + const next = new Set(prev); + let changed = false; + for (const id of ancestorIds) { + if (next.delete(id)) changed = true; + } + return changed ? next : prev; + }); + } + }, [focusedSpanId, trace.spanById, setCollapsedSpans]); + + // Scroll to focused span when using prev/next buttons in search bar. + useEffect(() => { + if (!focusedSpanId || !virtuosoRef.current) return; + + const index = rows.findIndex((r) => r.spanId === focusedSpanId); + if (index >= 0) { + virtuosoRef.current.scrollToIndex({ index, align: 'center' }); + } + }, [focusedSpanId, rows]); + + // Set the top most index in the Virtuoso table to the selected span + // Required e.g. when navigating from another page. + const initialTopMostSpanIndex = useMemo(() => { + if (!selectedSpan) return 0; + const index = rows.findIndex((r) => r.spanId === selectedSpan.spanId); + return index >= 0 ? index : 0; }, [rows, selectedSpan]); const divider = ; @@ -81,8 +121,9 @@ export function GanttTable(props: GanttTableProps): ReactElement { > ( ); } - -/** - * treeToRows recursively transforms the span tree to a list of rows and - * hides collapsed child spans. - */ -function treeToRows(rows: Span[], span: Span, collapsedSpans: string[]): void { - rows.push(span); - if (!collapsedSpans.includes(span.spanId)) { - for (const child of span.childSpans) { - treeToRows(rows, child, collapsedSpans); - } - } -} diff --git a/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableProvider.tsx b/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableProvider.tsx index b515dd0de..7c18a55a5 100644 --- a/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableProvider.tsx +++ b/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableProvider.tsx @@ -14,8 +14,8 @@ import { createContext, ReactElement, useContext, useState } from 'react'; interface GanttTableContextType { - collapsedSpans: string[]; - setCollapsedSpans: (s: string[]) => void; + collapsedSpans: Set; + setCollapsedSpans: React.Dispatch>>; visibleSpans: string[]; setVisibleSpans: (s: string[]) => void; /** can be a spanId, an empty string for the root span or undefined for no hover */ @@ -36,7 +36,7 @@ interface GanttTableProviderProps { export function GanttTableProvider(props: GanttTableProviderProps): ReactElement { const { children } = props; - const [collapsedSpans, setCollapsedSpans] = useState([]); + const [collapsedSpans, setCollapsedSpans] = useState>(new Set()); const [visibleSpans, setVisibleSpans] = useState([]); const [hoveredParent, setHoveredParent] = useState(undefined); diff --git a/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableRow.tsx b/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableRow.tsx index 773262632..c8742de7f 100644 --- a/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableRow.tsx +++ b/tracingganttchart/src/TracingGanttChart/GanttTable/GanttTableRow.tsx @@ -24,14 +24,19 @@ interface GanttTableRowProps { customLinks?: CustomLinks; span: Span; viewport: Viewport; + /** this span is opened in the attribute pane */ selected?: boolean; + /** this span is matched in the search results */ + matched?: boolean; + /** this span is focused by clicking prev/next in the search bar */ + focused?: boolean; nameColumnWidth: number; divider: React.ReactNode; onClick: (span: Span) => void; } export const GanttTableRow = memo(function GanttTableRow(props: GanttTableRowProps) { - const { options, customLinks, span, viewport, selected, nameColumnWidth, divider, onClick } = props; + const { options, customLinks, span, viewport, selected, matched, focused, nameColumnWidth, divider, onClick } = props; const theme = useTheme(); const handleOnClick = (): void => { @@ -41,9 +46,22 @@ export const GanttTableRow = memo(function GanttTableRow(props: GanttTableRowPro onClick(span); }; + let backgroundColor: string | undefined; + if (selected) { + backgroundColor = theme.palette.action.focus; + } else if (focused) { + backgroundColor = theme.palette.action.selected; + } else if (matched) { + backgroundColor = theme.palette.action.hover; + } + return ( diff --git a/tracingganttchart/src/TracingGanttChart/GanttTable/SpanIndents.tsx b/tracingganttchart/src/TracingGanttChart/GanttTable/SpanIndents.tsx index 99016c2fe..0f075db97 100644 --- a/tracingganttchart/src/TracingGanttChart/GanttTable/SpanIndents.tsx +++ b/tracingganttchart/src/TracingGanttChart/GanttTable/SpanIndents.tsx @@ -40,13 +40,17 @@ export function SpanIndents(props: SpanIndentsProps): ReactElement { const handleToggleClick = useCallback( (e: MouseEvent) => { e.stopPropagation(); - if (collapsedSpans.includes(span.spanId)) { - setCollapsedSpans(collapsedSpans.filter((spanId) => spanId !== span.spanId)); - } else { - setCollapsedSpans([...collapsedSpans, span.spanId]); - } + setCollapsedSpans((prev) => { + const next = new Set(prev); + if (next.has(span.spanId)) { + next.delete(span.spanId); + } else { + next.add(span.spanId); + } + return next; + }); }, - [span, collapsedSpans, setCollapsedSpans] + [span, setCollapsedSpans] ); const handleIconMouseEnter = useCallback(() => { @@ -78,7 +82,7 @@ export function SpanIndents(props: SpanIndentsProps): ReactElement { > {i === spans.length - 1 && span.childSpans.length > 0 && - (collapsedSpans.includes(span.spanId) ? ( + (collapsedSpans.has(span.spanId) ? ( ) : ( diff --git a/tracingganttchart/src/TracingGanttChart/MiniGanttChart/draw.ts b/tracingganttchart/src/TracingGanttChart/MiniGanttChart/draw.ts index d2def5616..092e61b7a 100644 --- a/tracingganttchart/src/TracingGanttChart/MiniGanttChart/draw.ts +++ b/tracingganttchart/src/TracingGanttChart/MiniGanttChart/draw.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Span, Trace } from '../trace'; +import { Span, Trace, forEachSpan } from '../trace'; import { minSpanWidthPx } from '../utils'; const MIN_BAR_HEIGHT = 1; @@ -47,13 +47,7 @@ export function drawSpans( ); ctx.fill(); y += yChange; - - for (const childSpan of span.childSpans) { - drawSpan(childSpan); - } }; - for (const rootSpan of trace.rootSpans) { - drawSpan(rootSpan); - } + forEachSpan(trace.rootSpans, drawSpan); } diff --git a/tracingganttchart/src/TracingGanttChart/Search.test.tsx b/tracingganttchart/src/TracingGanttChart/Search.test.tsx new file mode 100644 index 000000000..2225c43b2 --- /dev/null +++ b/tracingganttchart/src/TracingGanttChart/Search.test.tsx @@ -0,0 +1,145 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ChartsProvider, testChartsTheme } from '@perses-dev/components'; +import { fireEvent, screen } from '@testing-library/dom'; +import { render, renderHook, act, RenderResult } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { otlptracev1 } from '@perses-dev/core'; +import * as exampleTrace from '../test/traces/example_otlp.json'; +import { getTraceModel } from './trace'; +import { SearchBar, useSpanSearch } from './Search'; + +const trace = getTraceModel(exampleTrace as otlptracev1.TracesData); + +describe('useSpanSearch', () => { + it('returns no matches for empty query', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + expect(result.current.matchingSpanIds).toEqual([]); + expect(result.current.focusedMatchIndex).toBe(0); + }); + + it('matches spans by name', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('testChildSpan2')); + expect(result.current.matchingSpanIds).toEqual(['sid2']); + }); + + it('matches spans by service name', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('shop-backend')); + expect(result.current.matchingSpanIds).toEqual(['sid1', 'sid2', 'sid3']); + }); + + it('matches spans by span ID', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('sid3')); + expect(result.current.matchingSpanIds).toEqual(['sid3']); + }); + + it('matches spans by attribute key', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('http.method')); + expect(result.current.matchingSpanIds).toEqual(['sid2', 'sid3']); + }); + + it('matches spans by attribute value', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('DELETE')); + expect(result.current.matchingSpanIds).toEqual(['sid2']); + }); + + it('matches spans by resource attribute value', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('service.name')); + expect(result.current.matchingSpanIds).toEqual(['sid1', 'sid2', 'sid3']); + }); + + it('matches case-insensitively', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('TESTCHILDSPAN3')); + expect(result.current.matchingSpanIds).toEqual(['sid3']); + }); + + it('resets focused match index when query changes', () => { + const { result } = renderHook(() => useSpanSearch(trace)); + act(() => result.current.setSearchQuery('shop-backend')); + act(() => result.current.setFocusedMatchIndex(2)); + expect(result.current.focusedMatchIndex).toBe(2); + + act(() => result.current.setSearchQuery('testChildSpan2')); + expect(result.current.focusedMatchIndex).toBe(0); + }); +}); + +describe('SearchBar', () => { + function SearchBarWithHook(): ReactElement { + const search = useSpanSearch(trace); + return ; + } + + const renderComponent = (): RenderResult => { + return render( + + + + ); + }; + + it('navigates to next match with Enter', () => { + renderComponent(); + const input = screen.getByPlaceholderText('Search spans...'); + fireEvent.change(input, { target: { value: 'shop-backend' } }); + expect(screen.getByText('1/3')).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: 'Enter' }); + expect(screen.getByText('2/3')).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: 'Enter' }); + expect(screen.getByText('3/3')).toBeInTheDocument(); + + // wraps around + fireEvent.keyDown(input, { key: 'Enter' }); + expect(screen.getByText('1/3')).toBeInTheDocument(); + }); + + it('navigates to previous match with Shift+Enter', () => { + renderComponent(); + const input = screen.getByPlaceholderText('Search spans...'); + fireEvent.change(input, { target: { value: 'shop-backend' } }); + + // wraps around from first to last + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); + expect(screen.getByText('3/3')).toBeInTheDocument(); + + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); + expect(screen.getByText('2/3')).toBeInTheDocument(); + }); + + it('clears search when clear button is clicked', () => { + renderComponent(); + const input = screen.getByPlaceholderText('Search spans...') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'shop-backend' } }); + expect(input.value).toBe('shop-backend'); + + fireEvent.click(screen.getByLabelText('Clear search')); + expect(input.value).toBe(''); + }); + + it('displays 0/0 for no matches', () => { + renderComponent(); + const input = screen.getByPlaceholderText('Search spans...'); + fireEvent.change(input, { target: { value: 'nonexistent' } }); + expect(screen.getByText('0/0')).toBeInTheDocument(); + }); +}); diff --git a/tracingganttchart/src/TracingGanttChart/Search.tsx b/tracingganttchart/src/TracingGanttChart/Search.tsx new file mode 100644 index 000000000..0b86a3c88 --- /dev/null +++ b/tracingganttchart/src/TracingGanttChart/Search.tsx @@ -0,0 +1,130 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { IconButton, InputAdornment, Stack, TextField } from '@mui/material'; +import ChevronUp from 'mdi-material-ui/ChevronUp'; +import ChevronDown from 'mdi-material-ui/ChevronDown'; +import Close from 'mdi-material-ui/Close'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; +import { otlpcommonv1 } from '@perses-dev/core'; +import { renderAttributeValue } from './DetailPane/Attributes'; +import { Span, Trace, forEachSpan } from './trace'; + +export interface SearchBarProps { + search: SpanSearch; +} + +export function SearchBar(props: SearchBarProps): ReactElement { + const { search } = props; + const { searchQuery, setSearchQuery, matchingSpanIds, focusedMatchIndex, setFocusedMatchIndex } = search; + + const hasQuery = searchQuery.length > 0; + const matchCount = matchingSpanIds.length; + const hasMatches = matchCount > 0; + + function handlePrev(): void { + setFocusedMatchIndex((focusedMatchIndex - 1 + matchCount) % matchCount); + } + + function handleNext(): void { + setFocusedMatchIndex((focusedMatchIndex + 1) % matchCount); + } + + function handleKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) { + handlePrev(); + } else { + handleNext(); + } + } + } + + return ( + + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + slotProps={{ + input: { + endAdornment: ( + + {hasMatches ? `${focusedMatchIndex + 1}/${matchCount}` : '0/0'} + + ), + }, + }} + sx={{ minWidth: 200 }} + /> + + + + + + + setSearchQuery('')}> + + + + ); +} + +function spanMatchesQuery(span: Span, query: string): boolean { + const attrMatches = (attr: otlpcommonv1.KeyValue): boolean => + attr.key.toLowerCase().includes(query) || renderAttributeValue(attr.value).toLowerCase().includes(query); + + return ( + span.resource.serviceName?.toLowerCase().includes(query) || + span.name.toLowerCase().includes(query) || + span.spanId.toLowerCase().includes(query) || + span.attributes.some(attrMatches) || + span.resource.attributes.some(attrMatches) + ); +} + +export interface SpanSearch { + searchQuery: string; + setSearchQuery: (query: string) => void; + matchingSpanIds: string[]; + focusedMatchIndex: number; + setFocusedMatchIndex: (index: number) => void; +} + +export function useSpanSearch(trace: Trace): SpanSearch { + const [searchQuery, setSearchQueryRaw] = useState(''); + const [focusedMatchIndex, setFocusedMatchIndex] = useState(0); + + const matchingSpanIds = useMemo(() => { + if (searchQuery.length === 0) return []; + + const query = searchQuery.toLowerCase(); + const matches: string[] = []; + forEachSpan(trace.rootSpans, (span) => { + if (spanMatchesQuery(span, query)) { + matches.push(span.spanId); + } + }); + return matches; + }, [searchQuery, trace.rootSpans]); + + const setSearchQuery = useCallback((query: string) => { + setSearchQueryRaw(query); + setFocusedMatchIndex(0); + }, []); + + return { searchQuery, setSearchQuery, matchingSpanIds, focusedMatchIndex, setFocusedMatchIndex }; +} diff --git a/tracingganttchart/src/TracingGanttChart/TraceDetails.tsx b/tracingganttchart/src/TracingGanttChart/TraceDetails.tsx deleted file mode 100644 index f86dacdd1..000000000 --- a/tracingganttchart/src/TracingGanttChart/TraceDetails.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright The Perses Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { Stack, Typography } from '@mui/material'; -import { ReactElement } from 'react'; -import { useTimeZone } from '@perses-dev/components'; -import { formatDuration } from './utils'; -import { Trace } from './trace'; - -const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - fractionalSecondDigits: 3, - timeZoneName: 'short', -}; - -export interface TraceDetailsProps { - trace: Trace; -} - -export function TraceDetails(props: TraceDetailsProps): ReactElement { - const { trace } = props; - - const { dateFormatOptionsWithUserTimeZone } = useTimeZone(); - const dateFormatOptions = dateFormatOptionsWithUserTimeZone(DATE_FORMAT_OPTIONS); - const dateFormatter = new Intl.DateTimeFormat(undefined, dateFormatOptions); - - const rootSpan = trace.rootSpans[0]; - if (!rootSpan) { - return Trace contains no spans.; - } - - return ( - - - {rootSpan.resource.serviceName}: {rootSpan.name} ({formatDuration(trace.endTimeUnixMs - trace.startTimeUnixMs)}) - - - - Start: {dateFormatter.format(trace.startTimeUnixMs)} - - - Trace ID: {rootSpan.traceId} - - - - ); -} diff --git a/tracingganttchart/src/TracingGanttChart/TraceDetails.test.tsx b/tracingganttchart/src/TracingGanttChart/TraceHeaderBar.test.tsx similarity index 73% rename from tracingganttchart/src/TracingGanttChart/TraceDetails.test.tsx rename to tracingganttchart/src/TracingGanttChart/TraceHeaderBar.test.tsx index 97e312c0f..877648b5b 100644 --- a/tracingganttchart/src/TracingGanttChart/TraceDetails.test.tsx +++ b/tracingganttchart/src/TracingGanttChart/TraceHeaderBar.test.tsx @@ -17,20 +17,28 @@ import { MemoryRouter } from 'react-router-dom'; import { otlptracev1 } from '@perses-dev/core'; import * as exampleTrace from '../test/traces/example_otlp.json'; import { getTraceModel } from './trace'; -import { TraceDetails, TraceDetailsProps } from './TraceDetails'; +import { TraceHeaderBar, TraceHeaderBarProps } from './TraceHeaderBar'; +import { SpanSearch } from './Search'; -describe('TraceDetails', () => { +describe('TraceHeaderBar', () => { const trace = getTraceModel(exampleTrace as otlptracev1.TracesData); - const renderComponent = (props: TraceDetailsProps): RenderResult => { + const search: SpanSearch = { + searchQuery: '', + setSearchQuery: () => {}, + matchingSpanIds: [], + focusedMatchIndex: 0, + setFocusedMatchIndex: () => {}, + }; + const renderComponent = (props: TraceHeaderBarProps): RenderResult => { return render( - + ); }; it('render trace details', () => { - renderComponent({ trace }); + renderComponent({ trace, search }); expect(screen.getByRole('heading', { name: 'shop-backend: testRootSpan (1s)' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Trace ID: 5B8EFFF798038103D269B633813FC60C/ })).toBeInTheDocument(); }); diff --git a/tracingganttchart/src/TracingGanttChart/TraceHeaderBar.tsx b/tracingganttchart/src/TracingGanttChart/TraceHeaderBar.tsx new file mode 100644 index 000000000..7527c6bee --- /dev/null +++ b/tracingganttchart/src/TracingGanttChart/TraceHeaderBar.tsx @@ -0,0 +1,77 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { IconButton, Stack, Typography } from '@mui/material'; +import MagnifyIcon from 'mdi-material-ui/Magnify'; +import { ReactElement, useMemo, useState } from 'react'; +import { useTimeZone } from '@perses-dev/components'; +import { formatDuration } from './utils'; +import { Trace } from './trace'; +import { SearchBar, SpanSearch } from './Search'; + +const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + fractionalSecondDigits: 3, + timeZoneName: 'short', +}; + +export interface TraceHeaderBarProps { + trace: Trace; + search: SpanSearch; +} + +export function TraceHeaderBar(props: TraceHeaderBarProps): ReactElement { + const { trace, search } = props; + + const { dateFormatOptionsWithUserTimeZone } = useTimeZone(); + const dateFormatter = useMemo(() => { + const dateFormatOptions = dateFormatOptionsWithUserTimeZone(DATE_FORMAT_OPTIONS); + return new Intl.DateTimeFormat(undefined, dateFormatOptions); + }, [dateFormatOptionsWithUserTimeZone]); + const [showSearch, setShowSearch] = useState(false); + + const rootSpan = trace.rootSpans[0]; + if (!rootSpan) { + return Trace contains no spans.; + } + + return ( + + + + + {rootSpan.resource.serviceName}: {rootSpan.name} ( + {formatDuration(trace.endTimeUnixMs - trace.startTimeUnixMs)}) + + setShowSearch((prev) => !prev)} aria-label="Toggle search"> + + + + + + Start: {dateFormatter.format(trace.startTimeUnixMs)} + + + Trace ID: {rootSpan.traceId} + + + + {showSearch && } + + ); +} diff --git a/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx b/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx index 7eae5a9f8..ffd6aad8a 100644 --- a/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx +++ b/tracingganttchart/src/TracingGanttChart/TracingGanttChart.tsx @@ -22,7 +22,8 @@ import { GanttTable } from './GanttTable/GanttTable'; import { GanttTableProvider } from './GanttTable/GanttTableProvider'; import { ResizableDivider } from './GanttTable/ResizableDivider'; import { getTraceModel, Span } from './trace'; -import { TraceDetails } from './TraceDetails'; +import { TraceHeaderBar } from './TraceHeaderBar'; +import { useSpanSearch } from './Search'; export interface TracingGanttChartProps { options: TracingGanttChartOptions; @@ -53,6 +54,7 @@ export function TracingGanttChart(props: TracingGanttChartProps): ReactElement { const [selectedSpan, setSelectedSpan] = useState(() => options.selectedSpanId ? trace.spanById.get(options.selectedSpanId) : undefined ); + const search = useSpanSearch(trace); const ganttChart = useRef(null); // tableWidth only comes to effect if the detail pane is visible. @@ -64,7 +66,7 @@ export function TracingGanttChart(props: TracingGanttChartProps): ReactElement { return ( - + diff --git a/tracingganttchart/src/TracingGanttChart/trace.test.ts b/tracingganttchart/src/TracingGanttChart/trace.test.ts index b09e61f89..6b929d738 100644 --- a/tracingganttchart/src/TracingGanttChart/trace.test.ts +++ b/tracingganttchart/src/TracingGanttChart/trace.test.ts @@ -17,7 +17,7 @@ import * as missingRootSpanTrace from '../test/traces/pushbytes_no_root_span_otl import * as incompleteTrace from '../test/traces/pushbytes_incomplete_otlp.json'; import * as asyncTrace from '../test/traces/async_jaeger.json'; import { JaegerTrace, jaegerTraceToOTLP } from '../test/convert/jaeger'; -import { getTraceModel, Span } from './trace'; +import { forEachSpan, getTraceModel, Span } from './trace'; describe('trace', () => { it('computes a GanttTrace model from a trace', (): void => { @@ -50,6 +50,25 @@ describe('trace', () => { }); }); +describe('forEachSpan', () => { + it('iterates all spans depth-first', () => { + const names: string[] = []; + forEachSpan([spanTree], (span) => { + names.push(span.name); + }); + expect(names).toEqual(['testRootSpan', 'testChildSpan2', 'testChildSpan3']); + }); + + it('skips children when callback returns false', () => { + const names: string[] = []; + forEachSpan([spanTree], (span) => { + names.push(span.name); + if (span.spanId === 'sid2') return false; + }); + expect(names).toEqual(['testRootSpan', 'testChildSpan2']); + }); +}); + const spanTree = { resource: { serviceName: 'shop-backend', diff --git a/tracingganttchart/src/TracingGanttChart/trace.ts b/tracingganttchart/src/TracingGanttChart/trace.ts index 904b1aae4..ea406a5cd 100644 --- a/tracingganttchart/src/TracingGanttChart/trace.ts +++ b/tracingganttchart/src/TracingGanttChart/trace.ts @@ -123,6 +123,18 @@ export function getTraceModel(trace: otlptracev1.TracesData): Trace { return { trace, rootSpans, spanById, startTimeUnixMs, endTimeUnixMs }; } +/** + * Recursively iterates all spans depth-first. + * Return false from the callback to skip a span's children. + */ +export function forEachSpan(spans: Span[], fn: (span: Span) => boolean | void): void { + for (const span of spans) { + if (fn(span) !== false) { + forEachSpan(span.childSpans, fn); + } + } +} + function parseResource(resource?: otlpresourcev1.Resource): Resource { let serviceName = 'unknown'; for (const attr of resource?.attributes ?? []) {