diff --git a/projects/components/src/public-api.ts b/projects/components/src/public-api.ts index 280fcb179..38e153b9c 100644 --- a/projects/components/src/public-api.ts +++ b/projects/components/src/public-api.ts @@ -238,7 +238,7 @@ export * from './select/select.component'; export * from './select/select.module'; // Sequence -export { SequenceSegment } from './sequence/sequence'; +export { Marker, MarkerDatum, SequenceSegment } from './sequence/sequence'; export * from './sequence/sequence-chart.component'; export * from './sequence/sequence-chart.module'; diff --git a/projects/components/src/sequence/renderer/sequence-bar-renderer.service.ts b/projects/components/src/sequence/renderer/sequence-bar-renderer.service.ts index 44b75d2be..3c9748dad 100644 --- a/projects/components/src/sequence/renderer/sequence-bar-renderer.service.ts +++ b/projects/components/src/sequence/renderer/sequence-bar-renderer.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@angular/core'; +import { ElementRef, Injectable } from '@angular/core'; import { ScaleLinear } from 'd3-scale'; import { BaseType, select, Selection } from 'd3-selection'; import { SequenceChartAxisService } from '../axis/sequence-chart-axis.service'; -import { SequenceLayoutStyleClass, SequenceOptions, SequenceSegment, SequenceSVGSelection } from '../sequence'; +import { Marker, SequenceLayoutStyleClass, SequenceOptions, SequenceSegment, SequenceSVGSelection } from '../sequence'; import { SequenceObject } from '../sequence-object'; @Injectable() @@ -10,10 +10,14 @@ export class SequenceBarRendererService { private static readonly DATA_ROW_CLASS: string = 'data-row'; private static readonly HOVERED_ROW_CLASS: string = 'hovered-row'; private static readonly SELECTED_ROW_CLASS: string = 'selected-row'; + private static readonly MARKER_CLASS: string = 'marker'; + private static readonly MARKERS_CLASS: string = 'markers'; private static readonly BACKDROP_CLASS: string = 'backdrop'; private static readonly BACKDROP_BORDER_TOP_CLASS: string = 'backdrop-border-top'; private static readonly BACKDROP_BORDER_BOTTOM_CLASS: string = 'backdrop-border-bottom'; + private readonly markerWidth: number = 2; + public constructor(private readonly sequenceChartAxisService: SequenceChartAxisService) {} public drawBars(chartSelection: SequenceSVGSelection, options: SequenceOptions): void { @@ -39,9 +43,11 @@ export class SequenceBarRendererService { this.drawBackdropRect(transformedBars, options, plotWidth); this.drawBarValueRect(transformedBars, xScale, options); + this.drawBarMarkers(transformedBars, xScale, options); this.drawBarValueText(transformedBars, xScale, options); this.setupHoverListener(transformedBars, options); this.setupClickListener(transformedBars, options); + this.setupMarkerHoverListener(transformedBars, options); this.updateDataRowHover(chartSelection, options); this.updateDataRowSelection(chartSelection, options); } @@ -108,6 +114,23 @@ export class SequenceBarRendererService { .classed('bar-value', true); } + private drawBarMarkers( + transformedBars: TransformedBarSelection, + xScale: ScaleLinear, + options: SequenceOptions + ): void { + transformedBars + .selectAll(`g.${SequenceBarRendererService.MARKERS_CLASS}`) + .data(segment => this.getGroupedMarkers(segment, xScale)) + .enter() + .append('rect') + .classed(`${SequenceBarRendererService.MARKER_CLASS}`, true) + .attr('transform', dataRow => `translate(${dataRow.markerTime},${(options.rowHeight - options.barHeight) / 2})`) + .attr('width', this.markerWidth) + .attr('height', 12) + .style('fill', 'white'); + } + private drawBarValueText( transformedBars: TransformedBarSelection, xScale: ScaleLinear, @@ -175,6 +198,49 @@ export class SequenceBarRendererService { options.selected = options.selected === dataRow ? undefined : dataRow; options.onSegmentSelected(dataRow); } + + private setupMarkerHoverListener(transformedBars: TransformedBarSelection, options: SequenceOptions): void { + transformedBars + .selectAll(`rect.${SequenceBarRendererService.MARKER_CLASS}`) + .on('mouseenter', (dataRow, index, nodes) => { + options.onMarkerHovered({ marker: dataRow, origin: new ElementRef(nodes[index]) }); + }); + } + + private getGroupedMarkers(segment: SequenceSegment, xScale: ScaleLinear): Marker[] { + const scaledStart: number = Math.floor(xScale(segment.start)!); + const scaledEnd: number = Math.floor(xScale(segment.end)!); + const pixelScaledMarkers: Marker[] = segment.markers.map((marker: Marker) => ({ + ...marker, + markerTime: Math.floor(xScale(marker.markerTime)!) + })); + const scaledNormalizedMarkers: Marker[] = []; + let markerTime = -1 * Infinity; + let index = -1; + pixelScaledMarkers.forEach((marker: Marker) => { + // For 1px gap + if (marker.markerTime >= markerTime + this.markerWidth + 1) { + index++; + scaledNormalizedMarkers.push({ + ...marker, + markerTime: + marker.markerTime <= scaledStart + this.markerWidth // Grouping - closest to start + ? scaledStart + this.markerWidth + 1 + : marker.markerTime >= scaledEnd - this.markerWidth // Grouping - closest to end + ? scaledEnd - this.markerWidth - 2 + : marker.markerTime + }); + markerTime = scaledNormalizedMarkers[index].markerTime; + } else { + scaledNormalizedMarkers[index] = { + ...scaledNormalizedMarkers[index], + timestamps: [...scaledNormalizedMarkers[index].timestamps, ...marker.timestamps] + }; + } + }); + + return scaledNormalizedMarkers; + } } type TransformedBarSelection = Selection; diff --git a/projects/components/src/sequence/sequence-chart.component.scss b/projects/components/src/sequence/sequence-chart.component.scss index 17248925c..3a18d69aa 100644 --- a/projects/components/src/sequence/sequence-chart.component.scss +++ b/projects/components/src/sequence/sequence-chart.component.scss @@ -54,6 +54,10 @@ } } + .marker { + cursor: pointer; + } + .hovered-row { .backdrop { fill-opacity: 100; diff --git a/projects/components/src/sequence/sequence-chart.component.test.ts b/projects/components/src/sequence/sequence-chart.component.test.ts index 9665b7370..b05606408 100644 --- a/projects/components/src/sequence/sequence-chart.component.test.ts +++ b/projects/components/src/sequence/sequence-chart.component.test.ts @@ -24,28 +24,32 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] }, { id: '2', start: 1569357461346, end: 1569357465346, label: 'Segment 2', - color: 'green' + color: 'green', + markers: [] }, { id: '3', start: 1569357462346, end: 1569357465346, label: 'Segment 3', - color: 'green' + color: 'green', + markers: [] }, { id: '4', start: 1569357463346, end: 1569357465346, label: 'Segment 4', - color: 'green' + color: 'green', + markers: [] } ]; const chart = createHost(``, { @@ -91,7 +95,8 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] } ]; const chart = createHost(``, { @@ -117,7 +122,8 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] } ]; const chart = createHost(``, { @@ -132,6 +138,37 @@ describe('Sequence Chart component', () => { expect(dataRow!.getAttribute('height')).toEqual('50'); }); + test('should render marker with correct width and height', () => { + const segmentsData = [ + { + id: '1', + start: 1569357460346, + end: 1569357465346, + label: 'Segment 1', + color: 'blue', + markers: [ + { + id: '1', + markerTime: 1569357460347, + timestamps: ['1569357460347'] + } + ] + } + ]; + const chart = createHost(``, { + hostProps: { + data: segmentsData, + rowHeight: 50 + } + }); + + const markers = chart.queryAll('.marker', { root: true }); + expect(markers.length).toBe(1); + const markerRect = select(markers[0]); + expect(markerRect.attr('width')).toBe('2'); + expect(markerRect.attr('height')).toBe('12'); + }); + test('should render with correct bar height', () => { const segmentsData = [ { @@ -139,7 +176,8 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] } ]; const chart = createHost(``, { @@ -164,7 +202,8 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] } ]; const chart = createHost(``, { @@ -186,14 +225,16 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] }, { id: '2', start: 1569357460346, end: 1569357465346, label: 'Segment 2', - color: 'green' + color: 'green', + markers: [] } ]; const chart = createHost(``, { @@ -230,14 +271,16 @@ describe('Sequence Chart component', () => { start: 1569357460346, end: 1569357465346, label: 'Segment 1', - color: 'blue' + color: 'blue', + markers: [] }, { id: '2', start: 1569357460346, end: 1569357465346, label: 'Segment 2', - color: 'green' + color: 'green', + markers: [] } ]; const chart = createHost(``, { diff --git a/projects/components/src/sequence/sequence-chart.component.ts b/projects/components/src/sequence/sequence-chart.component.ts index aefda7e57..4b0e4a071 100644 --- a/projects/components/src/sequence/sequence-chart.component.ts +++ b/projects/components/src/sequence/sequence-chart.component.ts @@ -10,7 +10,7 @@ import { } from '@angular/core'; import { RecursivePartial, TypedSimpleChanges } from '@hypertrace/common'; import { minBy } from 'lodash-es'; -import { SequenceOptions, SequenceSegment } from './sequence'; +import { Marker, MarkerDatum, SequenceOptions, SequenceSegment } from './sequence'; import { SequenceChartService } from './sequence-chart.service'; import { SequenceObject } from './sequence-object'; @@ -52,6 +52,9 @@ export class SequenceChartComponent implements OnChanges { SequenceSegment | undefined >(); + @Output() + public readonly markerHoveredChange: EventEmitter = new EventEmitter(); + @ViewChild('chartContainer', { static: true }) private readonly chartContainer!: ElementRef; @@ -100,7 +103,8 @@ export class SequenceChartComponent implements OnChanges { barHeight: this.barHeight, unit: this.unit, onSegmentSelected: (segment?: SequenceSegment) => this.onSegmentSelected(segment), - onSegmentHovered: (segment?: SequenceSegment) => this.onSegmentHovered(segment) + onSegmentHovered: (segment?: SequenceSegment) => this.onSegmentHovered(segment), + onMarkerHovered: (datum?: MarkerDatum) => this.onMarkerHovered(datum) }; } @@ -115,7 +119,10 @@ export class SequenceChartComponent implements OnChanges { id: segment.id, start: segment.start - minStart, end: segment.end - minStart, - color: segment.color + color: segment.color, + markers: segment.markers + .map((marker: Marker) => ({ ...marker, markerTime: marker.markerTime - minStart })) + .sort((marker1, marker2) => marker1.markerTime - marker2.markerTime) })); } @@ -128,4 +135,8 @@ export class SequenceChartComponent implements OnChanges { this.hovered = segment; this.hoveredChange.emit(segment); } + + private onMarkerHovered(datum?: MarkerDatum): void { + this.markerHoveredChange.emit(datum); + } } diff --git a/projects/components/src/sequence/sequence-chart.service.ts b/projects/components/src/sequence/sequence-chart.service.ts index e69b6cca0..9a5360106 100644 --- a/projects/components/src/sequence/sequence-chart.service.ts +++ b/projects/components/src/sequence/sequence-chart.service.ts @@ -101,6 +101,9 @@ export class SequenceChartService { }, onSegmentHovered: () => { /** NOOP */ + }, + onMarkerHovered: () => { + /** NOOP */ } }; } diff --git a/projects/components/src/sequence/sequence.ts b/projects/components/src/sequence/sequence.ts index 3495d58d3..f5f124b87 100644 --- a/projects/components/src/sequence/sequence.ts +++ b/projects/components/src/sequence/sequence.ts @@ -1,3 +1,4 @@ +import { ElementRef } from '@angular/core'; import { Selection } from 'd3-selection'; import { SequenceObject } from './sequence-object'; @@ -6,6 +7,18 @@ export interface SequenceSegment { start: number; end: number; color: string; + markers: Marker[]; +} + +export interface Marker { + id: string; + markerTime: number; + timestamps: string[]; +} + +export interface MarkerDatum { + marker: Marker; + origin: ElementRef; } /* Internal Types */ @@ -20,6 +33,7 @@ export interface SequenceOptions { unit: string | undefined; onSegmentSelected(row?: SequenceSegment): void; onSegmentHovered(row?: SequenceSegment): void; + onMarkerHovered(datum?: MarkerDatum): void; } export interface MarginOptions { diff --git a/projects/components/src/tabs/content/tab-group.component.ts b/projects/components/src/tabs/content/tab-group.component.ts index 0688b413a..31b50f99b 100644 --- a/projects/components/src/tabs/content/tab-group.component.ts +++ b/projects/components/src/tabs/content/tab-group.component.ts @@ -1,5 +1,16 @@ -import { ChangeDetectionStrategy, Component, ContentChildren, QueryList } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + EventEmitter, + Input, + OnChanges, + Output, + QueryList +} from '@angular/core'; import { Color } from '@hypertrace/common'; +import { isEmpty } from 'lodash-es'; import { TabComponent } from './tab/tab.component'; @Component({ @@ -8,7 +19,12 @@ import { TabComponent } from './tab/tab.component'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
- +
@@ -22,7 +38,7 @@ import { TabComponent } from './tab/tab.component'; >
-
+
@@ -32,12 +48,41 @@ import { TabComponent } from './tab/tab.component';
` }) -export class TabGroupComponent { +export class TabGroupComponent implements OnChanges, AfterViewInit { @ContentChildren(TabComponent) public tabs!: QueryList; + @Input() + public activeTabLabel?: string; + + @Output() + public readonly activeTabLabelChange: EventEmitter = new EventEmitter(); + public activeTabIndex: number = 0; + public ngOnChanges(): void { + this.setActiveTabIndexBasedOnLabel(); + } + + public ngAfterViewInit(): void { + this.setActiveTabIndexBasedOnLabel(); + } + + public onSelectedTabChange(index: number): void { + this.activeTabIndex = index; + this.activeTabLabelChange.emit(this.tabs?.get(index)?.label); + } + + private setActiveTabIndexBasedOnLabel(): void { + if (!isEmpty(this.tabs) && !isEmpty(this.activeTabLabel)) { + this.tabs.forEach((tab: TabComponent, index: number) => { + if (tab.label === this.activeTabLabel) { + this.activeTabIndex = index; + } + }); + } + } + public getBackgroundColor(index: number): Color { return this.activeTabIndex === index ? Color.Gray9 : Color.Gray2; } diff --git a/projects/distributed-tracing/src/shared/components/span-detail/span-detail-tab.ts b/projects/distributed-tracing/src/shared/components/span-detail/span-detail-tab.ts new file mode 100644 index 000000000..0a0bb6b83 --- /dev/null +++ b/projects/distributed-tracing/src/shared/components/span-detail/span-detail-tab.ts @@ -0,0 +1,7 @@ +export enum SpanDetailTab { + Request = 'Request', + Response = 'Response', + Attributes = 'Attributes', + ExitCalls = 'Exit Calls', + Logs = 'Logs' +} diff --git a/projects/distributed-tracing/src/shared/components/span-detail/span-detail.component.ts b/projects/distributed-tracing/src/shared/components/span-detail/span-detail.component.ts index 810388f57..13f988aa9 100644 --- a/projects/distributed-tracing/src/shared/components/span-detail/span-detail.component.ts +++ b/projects/distributed-tracing/src/shared/components/span-detail/span-detail.component.ts @@ -3,6 +3,7 @@ import { TypedSimpleChanges } from '@hypertrace/common'; import { isEmpty } from 'lodash-es'; import { SpanData } from './span-data'; import { SpanDetailLayoutStyle } from './span-detail-layout-style'; +import { SpanDetailTab } from './span-detail-tab'; @Component({ selector: 'ht-span-detail', @@ -23,8 +24,8 @@ import { SpanDetailLayoutStyle } from './span-detail-layout-style'; - - + + - + - + - + - + = new EventEmitter(); public showRequestTab?: boolean; public showResponseTab?: boolean; public showExitCallsTab?: boolean; - public showLogEventstab?: boolean; + public showLogEventsTab?: boolean; public totalLogEvents?: number; public ngOnChanges(changes: TypedSimpleChanges): void { @@ -80,7 +84,7 @@ export class SpanDetailComponent implements OnChanges { this.showRequestTab = !isEmpty(this.spanData?.requestHeaders) || !isEmpty(this.spanData?.requestBody); this.showResponseTab = !isEmpty(this.spanData?.responseHeaders) || !isEmpty(this.spanData?.responseBody); this.showExitCallsTab = !isEmpty(this.spanData?.exitCallsBreakup); - this.showLogEventstab = !isEmpty(this.spanData?.logEvents); + this.showLogEventsTab = !isEmpty(this.spanData?.logEvents); this.totalLogEvents = (this.spanData?.logEvents ?? []).length; } } diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall-widget-renderer.component.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall-widget-renderer.component.ts index 3b6a27395..f8f7ed219 100644 --- a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall-widget-renderer.component.ts +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall-widget-renderer.component.ts @@ -12,11 +12,13 @@ import { ButtonStyle, OverlayService, SheetRef, SheetSize } from '@hypertrace/co import { WidgetRenderer } from '@hypertrace/dashboards'; import { Renderer } from '@hypertrace/hyperdash'; import { RendererApi, RENDERER_API } from '@hypertrace/hyperdash-angular'; +import { isEmpty } from 'lodash-es'; import { Observable } from 'rxjs'; import { SpanDetailLayoutStyle } from '../../../components/span-detail/span-detail-layout-style'; +import { SpanDetailTab } from '../../../components/span-detail/span-detail-tab'; import { WaterfallWidgetModel } from './waterfall-widget.model'; import { WaterfallData } from './waterfall/waterfall-chart'; -import { WaterfallChartComponent } from './waterfall/waterfall-chart.component'; +import { MarkerSelection, WaterfallChartComponent } from './waterfall/waterfall-chart.component'; @Renderer({ modelClass: WaterfallWidgetModel }) @Component({ @@ -50,6 +52,7 @@ import { WaterfallChartComponent } from './waterfall/waterfall-chart.component'; class="waterfall-widget" [data]="data" (selectionChange)="this.onTableRowSelection($event)" + (markerSelection)="this.onMarkerSelection($event)" > @@ -61,6 +64,7 @@ import { WaterfallChartComponent } from './waterfall/waterfall-chart.component'; [spanData]="this.selectedData" [showTitleHeader]="true" layout="${SpanDetailLayoutStyle.Vertical}" + [activeTabLabel]="this.activeTabLabel" (closed)="this.closeSheet()" > , @@ -95,13 +100,19 @@ export class WaterfallWidgetRendererComponent } public onTableRowSelection(selectedData: WaterfallData): void { - if (selectedData === this.selectedData) { + this.activeTabLabel = undefined; + if (selectedData === this.selectedData || isEmpty(selectedData)) { this.closeSheet(); } else { this.openSheet(selectedData); } } + public onMarkerSelection(selectedMarker: MarkerSelection): void { + this.activeTabLabel = SpanDetailTab.Logs; + this.openSheet(selectedMarker.selectedData!); + } + public onExpandAll(): void { this.waterfallChart.onExpandAll(); } diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.scss b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.scss new file mode 100644 index 000000000..644e2eb89 --- /dev/null +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.scss @@ -0,0 +1,49 @@ +@import 'color-palette'; +@import 'font'; + +.marker-tooltip-container { + @include body-small(white); + background-color: $gray-9; + box-shadow: 0px 4px 5px rgba(0, 0, 0, 0.2), 0px 3px 14px rgba(0, 0, 0, 0.12), 0px 8px 10px rgba(0, 0, 0, 0.14); + border-radius: 4px; + padding: 8px 12px; + margin: 8px; + width: 320px; + max-height: 172px; + + .tooltip-header { + display: flex; + align-items: center; + justify-content: space-between; + + .log-count { + display: flex; + align-items: center; + + .count { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + background-color: $gray-5; + margin-left: 4px; + border-radius: 4px; + } + } + } + + .divider { + margin: 4px 0; + border: 1px solid $gray-6; + } + + .view-all-text { + cursor: pointer; + width: max-content; + + &:hover { + color: $gray-2; + } + } +} diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.test.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.test.ts new file mode 100644 index 000000000..aa21a8be3 --- /dev/null +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.test.ts @@ -0,0 +1,54 @@ +import { RouterTestingModule } from '@angular/router/testing'; +import { createHostFactory, Spectator } from '@ngneat/spectator/jest'; +import { Observable, of } from 'rxjs'; +import { MarkerTooltipComponent, MarkerTooltipData } from './marker-tooltip.component'; +import { MarkerTooltipModule } from './marker-tooltip.module'; + +describe('Marker Tooltip Component', () => { + let spectator: Spectator; + + const createHost = createHostFactory({ + component: MarkerTooltipComponent, + imports: [MarkerTooltipModule, RouterTestingModule], + shallow: true + }); + + test('should have single time with summary', () => { + const data: Observable = of({ + relativeTimes: [2], + summary: 'test-summary' + }); + spectator = createHost(``, { + hostProps: { + data: data + } + }); + + expect(spectator.query('.marker-tooltip-container')).toExist(); + expect(spectator.query('.count')).toHaveText('1'); + expect(spectator.query('.time-range')).toHaveText('2ms'); + expect(spectator.query('.divider')).toExist(); + expect(spectator.query('.summary')).toHaveText('test-summary'); + expect(spectator.query('.view-all')).toExist(); + }); + + test('should have time range', () => { + const data: Observable = of({ + relativeTimes: [2, 3], + summary: 'test-summary' + }); + spectator = createHost(``, { + hostProps: { + data: data, + viewAll: { + emit: jest.fn() + } + } + }); + + expect(spectator.query('.time-range')).toHaveText('2ms - 3ms'); + const viewAllTextElement = spectator.query('.view-all-text'); + expect(viewAllTextElement).toExist(); + spectator.click(viewAllTextElement!); + }); +}); diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.ts new file mode 100644 index 000000000..455bce9f7 --- /dev/null +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'ht-marker-tooltip', + styleUrls: ['./marker-tooltip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+ Logs +
{{ data.relativeTimes.length }}
+
+
+ {{ data.relativeTimes[0] }}ms + {{ data.relativeTimes[0] }}ms - {{ data.relativeTimes[data.relativeTimes.length - 1] }}ms +
+
+
+
+ {{ data.summary }} +
+
+
...
+
View all >
+
+
+ ` +}) +export class MarkerTooltipComponent { + @Input() + public data?: Observable; + + @Output() + public readonly viewAll: EventEmitter = new EventEmitter(); +} + +export interface MarkerTooltipData { + relativeTimes: number[]; + summary: string; +} diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.module.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.module.ts new file mode 100644 index 000000000..a30ad098c --- /dev/null +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/marker-tooltip/marker-tooltip.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormattingModule } from '@hypertrace/common'; +import { LabelModule, PopoverModule } from '@hypertrace/components'; +import { MarkerTooltipComponent } from './marker-tooltip.component'; + +@NgModule({ + declarations: [MarkerTooltipComponent], + exports: [MarkerTooltipComponent], + imports: [PopoverModule, CommonModule, FormattingModule, LabelModule] +}) +export class MarkerTooltipModule {} diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.component.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.component.ts index ffe8ad722..05a0d1054 100644 --- a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.component.ts +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.component.ts @@ -1,6 +1,23 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output, + TemplateRef, + ViewChild +} from '@angular/core'; +import { DateCoercer, TypedSimpleChanges } from '@hypertrace/common'; import { CoreTableCellRendererType, + Marker, + MarkerDatum, + PopoverBackdrop, + PopoverPositionType, + PopoverRef, + PopoverRelativePositionLocation, + PopoverService, SequenceSegment, StatefulTableRow, TableColumnConfig, @@ -9,11 +26,10 @@ import { TableMode, TableStyle } from '@hypertrace/components'; -import { of } from 'rxjs'; - -import { TypedSimpleChanges } from '@hypertrace/common'; +import { Observable, of } from 'rxjs'; +import { MarkerTooltipData } from './marker-tooltip/marker-tooltip.component'; import { WaterfallTableCellType } from './span-name/span-name-cell-type'; -import { WaterfallData, WaterfallDataNode } from './waterfall-chart'; +import { LogEvent, WaterfallData, WaterfallDataNode } from './waterfall-chart'; import { WaterfallChartService } from './waterfall-chart.service'; @Component({ @@ -49,9 +65,13 @@ import { WaterfallChartService } from './waterfall-chart.service'; (selectionChange)="this.onSelection($event ? [$event] : [])" [hovered]="this.hoveredNode" (hoveredChange)="this.onHover($event)" + (markerHoveredChange)="this.onMarkerHover($event)" > + + + ` }) @@ -62,9 +82,15 @@ export class WaterfallChartComponent implements OnChanges { @Output() public readonly selectionChange: EventEmitter = new EventEmitter(); + @Output() + public readonly markerSelection: EventEmitter = new EventEmitter(); + @ViewChild('table') private readonly table!: TableComponent; + @ViewChild('markerTooltipTemplate') + private readonly markerTooltipTemplate!: TemplateRef; + public datasource?: TableDataSource; public readonly columnDefs: TableColumnConfig[] = [ { @@ -91,7 +117,15 @@ export class WaterfallChartComponent implements OnChanges { public selectedNode?: WaterfallDataNode; public hoveredNode?: WaterfallDataNode; - public constructor(private readonly waterfallChartService: WaterfallChartService) {} + public markerTooltipData?: Observable; + private marker?: Marker; + private popover?: PopoverRef; + private readonly dateCoercer: DateCoercer = new DateCoercer(); + + public constructor( + private readonly waterfallChartService: WaterfallChartService, + private readonly popoverService: PopoverService + ) {} public ngOnChanges(changes: TypedSimpleChanges): void { if (changes.data && this.data) { @@ -131,6 +165,58 @@ export class WaterfallChartComponent implements OnChanges { this.hoveredNode = datum?.id !== undefined ? this.dataNodeMap.get(datum.id) : undefined; } + public viewAll(): void { + this.markerSelection.emit({ + selectedData: this.marker?.id !== undefined ? this.dataNodeMap.get(this.marker?.id) : undefined, + timestamps: this.marker?.timestamps ?? [] + }); + this.closePopover(); + } + + public onMarkerHover(datum?: MarkerDatum): void { + if (datum && datum.marker && datum.origin) { + this.closePopover(); + this.marker = datum.marker; + this.markerTooltipData = this.buildMarkerDataSource(datum.marker); + this.popover = this.popoverService.drawPopover({ + componentOrTemplate: this.markerTooltipTemplate, + data: this.markerTooltipTemplate, + position: { + type: PopoverPositionType.Relative, + origin: datum.origin, + locationPreferences: [PopoverRelativePositionLocation.AboveRightAligned] + }, + backdrop: PopoverBackdrop.Transparent + }); + this.popover.closeOnBackdropClick(); + } + } + + private closePopover(): void { + this.popover?.close(); + this.popover = undefined; + } + + private buildMarkerDataSource(marker: Marker): Observable { + const spanWaterfallData: WaterfallDataNode = this.dataNodeMap.get(marker.id)!; + + let markerData: MarkerTooltipData = { + relativeTimes: [], + summary: spanWaterfallData.logEvents[0].summary + }; + spanWaterfallData.logEvents.forEach((logEvent: LogEvent) => { + if (marker.timestamps.includes(logEvent.timestamp)) { + const logEventTime = this.dateCoercer.coerce(logEvent.timestamp)!.getTime(); + markerData = { + ...markerData, + relativeTimes: [...markerData.relativeTimes, logEventTime - spanWaterfallData.startTime] + }; + } + }); + + return of(markerData); + } + private buildDatasource(sequenceData: WaterfallData[]): TableDataSource { const rootLevelRows = this.buildAndCollectSequenceRootNodes(sequenceData); @@ -194,3 +280,8 @@ export class WaterfallChartComponent implements OnChanges { return dataNode.$$state.children.every(child => child.$$state.expanded); } } + +export interface MarkerSelection { + selectedData: WaterfallDataNode | undefined; + timestamps: string[]; +} diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.module.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.module.ts index aff1f69e6..d4134a661 100644 --- a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.module.ts +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormattingModule } from '@hypertrace/common'; import { IconModule, SequenceChartModule, TableModule, TooltipModule } from '@hypertrace/components'; +import { MarkerTooltipModule } from './marker-tooltip/marker-tooltip.module'; import { SpanNameTableCellParser } from './span-name/span-name-table-cell-parser'; import { SpanNameTableCellRendererComponent } from './span-name/span-name-table-cell-renderer.component'; import { WaterfallChartComponent } from './waterfall-chart.component'; @@ -15,7 +16,8 @@ import { WaterfallChartComponent } from './waterfall-chart.component'; TableModule.withCellRenderers([SpanNameTableCellRendererComponent]), TooltipModule, IconModule, - FormattingModule + FormattingModule, + MarkerTooltipModule ], exports: [WaterfallChartComponent] }) diff --git a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.service.ts b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.service.ts index e98e18f20..8c67928b1 100644 --- a/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.service.ts +++ b/projects/distributed-tracing/src/shared/dashboard/widgets/waterfall/waterfall/waterfall-chart.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@angular/core'; -import { Color, ColorService } from '@hypertrace/common'; +import { Color, ColorService, DateCoercer } from '@hypertrace/common'; import { SequenceSegment } from '@hypertrace/components'; import { isNil, sortBy } from 'lodash-es'; import { of } from 'rxjs'; import { TracingIconLookupService } from '../../../../services/icon-lookup/tracing-icon-lookup.service'; -import { WaterfallData, WaterfallDataNode } from './waterfall-chart'; +import { LogEvent, WaterfallData, WaterfallDataNode } from './waterfall-chart'; @Injectable({ providedIn: 'root' }) export class WaterfallChartService { private static readonly SEQUENCE_COLORS: symbol = Symbol('Sequence colors'); + private readonly dateCoercer: DateCoercer = new DateCoercer(); public constructor( private readonly colorService: ColorService, private readonly iconLookupService: TracingIconLookupService @@ -89,7 +90,12 @@ export class WaterfallChartService { id: node.id, start: node.startTime, end: node.endTime, - color: node.color! + color: node.color!, + markers: node.logEvents.map((logEvent: LogEvent) => ({ + id: node.id, + markerTime: this.dateCoercer.coerce(logEvent.timestamp)!.getTime(), + timestamps: [logEvent.timestamp] + })) }); sequenceNodes.unshift(...node.$$state.children);