diff --git a/projects/observability/src/pages/apis/topology/application-flow.component.ts b/projects/observability/src/pages/apis/topology/application-flow.component.ts index d0344d1d6..938ec8bd8 100644 --- a/projects/observability/src/pages/apis/topology/application-flow.component.ts +++ b/projects/observability/src/pages/apis/topology/application-flow.component.ts @@ -1,6 +1,14 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MetricAggregationType } from '@hypertrace/distributed-tracing'; import { ModelJson } from '@hypertrace/hyperdash'; +import { + defaultPrimaryEdgeMetricCategories, + defaultSecondaryEdgeMetricCategories +} from './../../../shared/dashboard/widgets/topology/metric/edge-metric-category'; +import { + defaultPrimaryNodeMetricCategories, + defaultSecondaryNodeMetricCategories +} from './../../../shared/dashboard/widgets/topology/metric/node-metric-category'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,78 +34,182 @@ export class ApplicationFlowComponent { type: 'topology-data-source', entity: 'SERVICE', 'downstream-entities': ['SERVICE', 'BACKEND'], - 'node-metrics': [ - { - type: 'percentile-latency-metric-aggregation', - 'display-name': 'P99 Latency' + 'edge-metrics': { + type: 'topology-metrics', + primary: { + type: 'topology-metric-with-category', + specification: { + type: 'percentile-latency-metric-aggregation', + 'display-name': 'P99 Latency' + }, + categories: [ + { + type: 'topology-metric-category', + ...defaultPrimaryEdgeMetricCategories[0] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryEdgeMetricCategories[1] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryEdgeMetricCategories[2] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryEdgeMetricCategories[3] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryEdgeMetricCategories[4] + } + ] }, - { - type: 'metric-aggregation', - metric: 'duration', - aggregation: MetricAggregationType.P50, - 'display-name': 'P50 Latency' + secondary: { + type: 'topology-metric-with-category', + specification: { + type: 'error-percentage-metric-aggregation', + aggregation: MetricAggregationType.Average, + 'display-name': 'Error %' + }, + categories: [ + { + type: 'topology-metric-category', + ...defaultSecondaryEdgeMetricCategories[0] + }, + { + type: 'topology-metric-category', + ...defaultSecondaryEdgeMetricCategories[1] + } + ] }, - { - type: 'error-percentage-metric-aggregation', - aggregation: MetricAggregationType.Average, - 'display-name': 'Error %' + others: [ + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'duration', + aggregation: MetricAggregationType.P50, + 'display-name': 'P50 Latency' + } + }, + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'errorCount', + aggregation: MetricAggregationType.Sum, + 'display-name': 'Error Count' + } + }, + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'numCalls', + aggregation: MetricAggregationType.AvgrateSecond, + 'display-name': 'Call Rate/sec' + } + }, + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'numCalls', + aggregation: MetricAggregationType.Sum, + 'display-name': 'Call Count' + } + } + ] + }, + 'node-metrics': { + type: 'topology-metrics', + primary: { + type: 'topology-metric-with-category', + specification: { + type: 'percentile-latency-metric-aggregation', + 'display-name': 'P99 Latency' + }, + categories: [ + { + type: 'topology-metric-category', + ...defaultPrimaryNodeMetricCategories[0] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryNodeMetricCategories[1] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryNodeMetricCategories[2] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryNodeMetricCategories[3] + }, + { + type: 'topology-metric-category', + ...defaultPrimaryNodeMetricCategories[4] + } + ] }, - - { - type: 'metric-aggregation', - metric: 'errorCount', - aggregation: MetricAggregationType.Sum, - 'display-name': 'Error Count' - }, - { - type: 'metric-aggregation', - metric: 'numCalls', - aggregation: MetricAggregationType.AvgrateSecond, - 'display-name': 'Call Rate/sec' - }, - { - type: 'metric-aggregation', - metric: 'numCalls', - aggregation: MetricAggregationType.Sum, - 'display-name': 'Call Count' - } - ], - 'edge-metrics': [ - { - type: 'percentile-latency-metric-aggregation', - 'display-name': 'P99 Latency' - }, - { - type: 'metric-aggregation', - metric: 'duration', - aggregation: MetricAggregationType.P50, - 'display-name': 'P50 Latency' - }, - { - type: 'error-percentage-metric-aggregation', - aggregation: MetricAggregationType.Average, - 'display-name': 'Error %' - }, - - { - type: 'metric-aggregation', - metric: 'errorCount', - aggregation: MetricAggregationType.Sum, - 'display-name': 'Error Count' - }, - { - type: 'metric-aggregation', - metric: 'numCalls', - aggregation: MetricAggregationType.AvgrateSecond, - 'display-name': 'Call Rate/sec' + secondary: { + type: 'topology-metric-with-category', + specification: { + type: 'error-percentage-metric-aggregation', + aggregation: MetricAggregationType.Average, + 'display-name': 'Error %' + }, + categories: [ + { + type: 'topology-metric-category', + ...defaultSecondaryNodeMetricCategories[0] + }, + { + type: 'topology-metric-category', + ...defaultSecondaryNodeMetricCategories[1] + } + ] }, - { - type: 'metric-aggregation', - metric: 'numCalls', - aggregation: MetricAggregationType.Sum, - 'display-name': 'Call Count' - } - ] + others: [ + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'duration', + aggregation: MetricAggregationType.P50, + 'display-name': 'P50 Latency' + } + }, + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'errorCount', + aggregation: MetricAggregationType.Sum, + 'display-name': 'Error Count' + } + }, + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'numCalls', + aggregation: MetricAggregationType.AvgrateSecond, + 'display-name': 'Call Rate/sec' + } + }, + { + type: 'topology-metric-with-category', + specification: { + type: 'metric-aggregation', + metric: 'numCalls', + aggregation: MetricAggregationType.Sum, + 'display-name': 'Call Count' + } + } + ] + } } } ] diff --git a/projects/observability/src/shared/dashboard/data/graphql/observability-graphql-data-source.module.ts b/projects/observability/src/shared/dashboard/data/graphql/observability-graphql-data-source.module.ts index e0e2f9690..95faf3401 100644 --- a/projects/observability/src/shared/dashboard/data/graphql/observability-graphql-data-source.module.ts +++ b/projects/observability/src/shared/dashboard/data/graphql/observability-graphql-data-source.module.ts @@ -28,6 +28,9 @@ import { PercentileLatencyAggregationSpecificationModel } from './specifiers/per import { EntityTableDataSourceModel } from './table/entity/entity-table-data-source.model'; import { ExploreTableDataSourceModel } from './table/explore/explore-table-data-source.model'; import { InteractionsTableDataSourceModel } from './table/interactions/interactions-table-data-source.model'; +import { TopologyMetricCategoryModel } from './topology/metrics/topology-metric-category.model'; +import { TopologyMetricWithCategoryModel } from './topology/metrics/topology-metric-with-category.model'; +import { TopologyMetricsModel } from './topology/metrics/topology-metrics.model'; import { TopologyDataSourceModel } from './topology/topology-data-source.model'; import { TraceMetricAggregationDataSourceModel } from './trace/aggregation/trace-metric-aggregation-data-source.model'; import { TraceDonutDataSourceModel } from './trace/donut/trace-donut-data-source.model'; @@ -53,6 +56,9 @@ import { ApiTraceWaterfallDataSourceModel } from './waterfall/api-trace-waterfal EntityMetricTimeseriesDataSourceModel, EntityMetricAggregationDataSourceModel, TopologyDataSourceModel, + TopologyMetricsModel, + TopologyMetricWithCategoryModel, + TopologyMetricCategoryModel, EntityTableDataSourceModel, InteractionsTableDataSourceModel, EntityAttributeDataSourceModel, diff --git a/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.test.ts b/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.test.ts index e4c6192f0..c23e32803 100644 --- a/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.test.ts +++ b/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.test.ts @@ -1,22 +1,75 @@ -import { isEqualIgnoreFunctions } from '@hypertrace/common'; +import { Color } from '@hypertrace/common'; import { GraphQlTimeRange, MetricAggregationType } from '@hypertrace/distributed-tracing'; import { GraphQlRequestCacheability, GraphQlRequestOptions } from '@hypertrace/graphql-client'; import { ModelApi } from '@hypertrace/hyperdash'; import { ObservabilityEntityType } from '../../../../graphql/model/schema/entity'; -import { ObservabilitySpecificationBuilder } from '../../../../graphql/request/builders/selections/observability-specification-builder'; import { ENTITY_TOPOLOGY_GQL_REQUEST, TopologyNodeSpecification } from '../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; +import { MetricAggregationSpecificationModel } from '../specifiers/metric-aggregation-specification.model'; +import { TopologyMetricCategoryModel } from './metrics/topology-metric-category.model'; +import { TopologyMetricWithCategoryModel } from './metrics/topology-metric-with-category.model'; +import { TopologyMetricsModel } from './metrics/topology-metrics.model'; import { TopologyDataSourceModel } from './topology-data-source.model'; describe('topology data source model', () => { - const specBuilder = new ObservabilitySpecificationBuilder(); const testTimeRange = { startTime: new Date(1568907645141), endTime: new Date(1568911245141) }; let model!: TopologyDataSourceModel; let lastEmittedQuery: unknown; let lastEmittedQueryRequestOption: GraphQlRequestOptions | undefined; + const createCategoryModel = ( + name: string, + minValue: number, + fillColor: Color, + strokeColor: Color, + focusColor: Color, + maxValue?: number + ): TopologyMetricCategoryModel => { + const categoryModel = new TopologyMetricCategoryModel(); + categoryModel.name = name; + categoryModel.minValue = minValue; + categoryModel.maxValue = maxValue; + categoryModel.fillColor = fillColor; + categoryModel.strokeColor = strokeColor; + categoryModel.focusColor = focusColor; + + return categoryModel; + }; + + const createSpecificationModel = (metric: string, aggregation: MetricAggregationType) => { + const specification = new MetricAggregationSpecificationModel(); + specification.metric = metric; + specification.aggregation = aggregation; + + specification.modelOnInit(); + + return specification; + }; + + const createMetricWithCategory = ( + spec: MetricAggregationSpecificationModel, + categories: TopologyMetricCategoryModel[] + ) => { + const withCategoryModel = new TopologyMetricWithCategoryModel(); + withCategoryModel.specification = spec; + withCategoryModel.categories = categories; + + return withCategoryModel; + }; + + const createTopologyMetricsModel = (metric: string, aggregation: MetricAggregationType) => { + const primary = createMetricWithCategory(createSpecificationModel(metric, aggregation), [ + createCategoryModel(metric, 0, Color.Blue2, Color.Blue3, Color.Blue4, 10) + ]); + + const metricsModel: TopologyMetricsModel = new TopologyMetricsModel(); + metricsModel.primary = primary; + + return metricsModel; + }; + beforeEach(() => { const mockApi: Partial = { getTimeRange: jest.fn(() => testTimeRange) @@ -25,12 +78,9 @@ describe('topology data source model', () => { model.downstreamEntityTypes = [ObservabilityEntityType.Api, ObservabilityEntityType.Backend]; model.upstreamEntityTypes = [ObservabilityEntityType.Service]; model.entityType = ObservabilityEntityType.Service; - model.nodeMetricSpecifications = [ - specBuilder.metricAggregationSpecForKey('numCalls', MetricAggregationType.Average) - ]; - model.edgeMetricSpecifications = [ - specBuilder.metricAggregationSpecForKey('duration', MetricAggregationType.Average) - ]; + model.nodeMetricsModel = createTopologyMetricsModel('numCalls', MetricAggregationType.Average); + model.edgeMetricsModel = createTopologyMetricsModel('duration', MetricAggregationType.Average); + model.api = mockApi as ModelApi; model.query$.subscribe(query => { lastEmittedQuery = query.buildRequest([]); @@ -41,47 +91,55 @@ describe('topology data source model', () => { test('builds expected request', () => { model.getData(); - expect( - isEqualIgnoreFunctions(lastEmittedQuery, { - requestType: ENTITY_TOPOLOGY_GQL_REQUEST, - rootNodeType: ObservabilityEntityType.Service, - rootNodeSpecification: { - titleSpecification: specBuilder.attributeSpecificationForKey('name'), - metricSpecifications: [specBuilder.metricAggregationSpecForKey('numCalls', MetricAggregationType.Average)] - }, - rootNodeFilters: [], - rootNodeLimit: 100, - timeRange: new GraphQlTimeRange(testTimeRange.startTime, testTimeRange.endTime), - downstreamNodeSpecifications: new Map([ - [ - ObservabilityEntityType.Api, - { - titleSpecification: specBuilder.attributeSpecificationForKey('name'), - metricSpecifications: [specBuilder.metricAggregationSpecForKey('numCalls', MetricAggregationType.Average)] - } - ], - [ - ObservabilityEntityType.Backend, - { - titleSpecification: specBuilder.attributeSpecificationForKey('name'), - metricSpecifications: [specBuilder.metricAggregationSpecForKey('numCalls', MetricAggregationType.Average)] - } - ] - ]), - upstreamNodeSpecifications: new Map([ - [ - ObservabilityEntityType.Service, - { - titleSpecification: specBuilder.attributeSpecificationForKey('name'), - metricSpecifications: [specBuilder.metricAggregationSpecForKey('numCalls', MetricAggregationType.Average)] - } - ] - ]), - edgeSpecification: { - metricSpecifications: [specBuilder.metricAggregationSpecForKey('duration', MetricAggregationType.Average)] - } - }) - ).toBe(true); + expect(lastEmittedQuery).toEqual({ + requestType: ENTITY_TOPOLOGY_GQL_REQUEST, + rootNodeType: ObservabilityEntityType.Service, + rootNodeSpecification: { + titleSpecification: expect.objectContaining({ name: 'name' }), + metricSpecifications: [ + expect.objectContaining({ metric: 'numCalls', aggregation: MetricAggregationType.Average }) + ] + }, + rootNodeFilters: [], + rootNodeLimit: 100, + timeRange: new GraphQlTimeRange(testTimeRange.startTime, testTimeRange.endTime), + downstreamNodeSpecifications: new Map([ + [ + ObservabilityEntityType.Api, + { + titleSpecification: expect.objectContaining({ name: 'name' }), + metricSpecifications: [ + expect.objectContaining({ metric: 'numCalls', aggregation: MetricAggregationType.Average }) + ] + } + ], + [ + ObservabilityEntityType.Backend, + { + titleSpecification: expect.objectContaining({ name: 'name' }), + metricSpecifications: [ + expect.objectContaining({ metric: 'numCalls', aggregation: MetricAggregationType.Average }) + ] + } + ] + ]), + upstreamNodeSpecifications: new Map([ + [ + ObservabilityEntityType.Service, + { + titleSpecification: expect.objectContaining({ name: 'name' }), + metricSpecifications: [ + expect.objectContaining({ metric: 'numCalls', aggregation: MetricAggregationType.Average }) + ] + } + ] + ]), + edgeSpecification: { + metricSpecifications: [ + expect.objectContaining({ metric: 'duration', aggregation: MetricAggregationType.Average }) + ] + } + }); expect(lastEmittedQueryRequestOption).toEqual({ cacheability: GraphQlRequestCacheability.Cacheable, diff --git a/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.ts b/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.ts index 2cf33f001..faefa584b 100644 --- a/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.ts +++ b/projects/observability/src/shared/dashboard/data/graphql/topology/topology-data-source.model.ts @@ -1,7 +1,13 @@ import { ArrayPropertyTypeInstance, EnumPropertyTypeInstance, ENUM_TYPE } from '@hypertrace/dashboards'; import { GraphQlDataSourceModel, SpecificationBuilder } from '@hypertrace/distributed-tracing'; import { GraphQlRequestCacheability, GraphQlRequestOptions } from '@hypertrace/graphql-client'; -import { ARRAY_PROPERTY, Model, ModelProperty, ModelPropertyType } from '@hypertrace/hyperdash'; +import { + ARRAY_PROPERTY, + Model, + ModelModelPropertyTypeInstance, + ModelProperty, + ModelPropertyType +} from '@hypertrace/hyperdash'; import { uniq } from 'lodash-es'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -14,7 +20,7 @@ import { TopologyEdgeSpecification, TopologyNodeSpecification } from '../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; -import { EntityMetricAggregationDataSourceModel } from '../entity/aggregation/entity-metric-aggregation-data-source.model'; +import { TopologyMetricsData, TopologyMetricsModel } from './metrics/topology-metrics.model'; @Model({ type: 'topology-data-source' @@ -61,27 +67,21 @@ export class TopologyDataSourceModel extends GraphQlDataSourceModel { const rootEntitySpec = this.buildEntitySpec(); const edgeSpec = { - metricSpecifications: this.edgeMetricSpecifications + metricSpecifications: this.getAllMetricSpecifications(this.edgeMetricsModel) }; return this.query( @@ -116,7 +116,9 @@ export class TopologyDataSourceModel extends GraphQlDataSourceModel _.specification) : []) + ]; + } } export interface TopologyData { @@ -151,4 +161,6 @@ export interface TopologyData { nodeTypes: ObservabilityEntityType[]; nodeSpecification: TopologyNodeSpecification; edgeSpecification: TopologyEdgeSpecification; + nodeMetrics: TopologyMetricsData; + edgeMetrics: TopologyMetricsData; } diff --git a/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.scss b/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.scss index cb316fac5..855f71e51 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.scss +++ b/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.scss @@ -18,7 +18,7 @@ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; - stroke: currentColor; + stroke: $gray-3; fill: none; @include chart-small-regular; @@ -34,7 +34,6 @@ .edge-path { stroke-dasharray: 2, 2; stroke-width: 1px; - stroke: $gray-3; } &.background { @@ -50,7 +49,6 @@ &.focused { .entity-edge-metric-bubble { @include show; - fill: currentColor; } .entity-edge-metric-value { @@ -65,35 +63,6 @@ .edge-path { stroke-dasharray: none; stroke-width: 1.5px; - stroke: currentColor; - } - - &.less-than-20-category { - color: $blue-gray-1; - } - - &.from-20-to-100-category { - color: $blue-gray-2; - } - - &.from-100-to-500-category { - color: $blue-gray-3; - } - - &.from-500-to-1000-category { - color: $blue-gray-4; - } - - &.greater-than-or-equal-to-1000-category { - color: $blue-gray-5; - } - - &.not-specified-category { - color: lightgray; - } - - &.greater-than-or-equal-to-5-error-category { - color: $red-5; } } } @@ -101,35 +70,6 @@ .entity-edge-arrow { stroke-linecap: round; stroke-linejoin: round; - fill: currentColor; - - &.less-than-20-category { - color: $blue-gray-1; - } - - &.from-20-to-100-category { - color: $blue-gray-2; - } - - &.from-100-to-500-category { - color: $blue-gray-3; - } - - &.from-500-to-1000-category { - color: $blue-gray-4; - } - - &.greater-than-or-equal-to-1000-category { - color: $blue-gray-5; - } - - &.not-specified-category { - color: lightgray; - } - - &.greater-than-or-equal-to-5-error-category { - color: $red-5; - } } } } diff --git a/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.service.ts b/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.service.ts index a60671e7f..da93fea4b 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.service.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/edge/curved/entity-edge-curve-renderer.service.ts @@ -1,11 +1,8 @@ import { Injectable, Renderer2 } from '@angular/core'; -import { DomElementMeasurerService, NumericFormatter, selector } from '@hypertrace/common'; +import { Color, DomElementMeasurerService, NumericFormatter, selector } from '@hypertrace/common'; +import { MetricAggregation } from '@hypertrace/distributed-tracing'; import { select, Selection } from 'd3-selection'; import { linkHorizontal } from 'd3-shape'; -import { - ErrorPercentageMetricAggregation, - ErrorPercentageMetricValueCategory -} from '../../../../..//graphql/model/schema/specifications/error-percentage-aggregation-specification'; import { TopologyEdgePositionInformation, TopologyEdgeRenderDelegate @@ -19,19 +16,12 @@ import { import { D3UtilService } from '../../../../../components/utils/d3/d3-util.service'; import { SvgUtilService } from '../../../../../components/utils/svg/svg-util.service'; import { MetricAggregationSpecification } from '../../../../../graphql/model/schema/specifications/metric-aggregation-specification'; -import { PercentileLatencyMetricAggregation } from '../../../../../graphql/model/schema/specifications/percentile-latency-aggregation-specification'; import { EntityEdge } from '../../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; -import { - allErrorPercentageMetricCategories, - allLatencyMetricCategories, - getErrorPercentageCategoryClass, - getErrorPercentageMetric, - getLatencyCategoryClass, - getLatencyMetric -} from '../../metric/metric-category'; +import { TopologyMetricCategoryData } from '../../../../data/graphql/topology/metrics/topology-metric-category.model'; +import { TopologyDataSourceModelPropertiesService } from '../../topology-data-source-model-properties.service'; import { VisibilityUpdater } from '../../visibility-updater'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class EntityEdgeCurveRendererService implements TopologyEdgeRenderDelegate { private readonly edgeClass: string = 'entity-edge'; private readonly edgeLineClass: string = 'entity-edge-line'; @@ -44,7 +34,8 @@ export class EntityEdgeCurveRendererService implements TopologyEdgeRenderDelegat public constructor( private readonly domElementMeasurerService: DomElementMeasurerService, private readonly svgUtils: SvgUtilService, - private readonly d3Utils: D3UtilService + private readonly d3Utils: D3UtilService, + private readonly topologyDataSourceModelPropertiesService: TopologyDataSourceModelPropertiesService ) {} public matches(edge: TopologyEdge & Partial): edge is EntityEdge { @@ -90,13 +81,17 @@ export class EntityEdgeCurveRendererService implements TopologyEdgeRenderDelegat domRenderer: Renderer2 ): void { const selection = this.d3Utils.select(element, domRenderer); - const metricSpecifications = state.dataSpecifiers?.map(specifier => specifier.value); + + const primaryMetric = this.topologyDataSourceModelPropertiesService.getPrimaryEdgeMetric(); + const secondaryMetric = this.topologyDataSourceModelPropertiesService.getSecondaryEdgeMetric(); this.updateEdgeMetric( selection, state.visibility, - getLatencyMetric(edge.data, metricSpecifications), - getErrorPercentageMetric(edge.data, metricSpecifications) + primaryMetric?.extractAndGetDataCategoryForMetric(edge.data), + secondaryMetric?.extractAndGetDataCategoryForMetric(edge.data), + primaryMetric?.extractDataForMetric(edge.data), + secondaryMetric?.extractDataForMetric(edge.data) ); this.visibilityUpdater.updateVisibility(selection, state.visibility); @@ -107,22 +102,42 @@ export class EntityEdgeCurveRendererService implements TopologyEdgeRenderDelegat protected updateEdgeMetric( selection: Selection, visibility: TopologyElementVisibility, - latencyMetric?: PercentileLatencyMetricAggregation, - errorPercentageMetric?: ErrorPercentageMetricAggregation + primaryMetricCategory?: TopologyMetricCategoryData, + secondaryMetricCategory?: TopologyMetricCategoryData, + primaryMetricAggregation?: MetricAggregation, + secondaryMetricAggregation?: MetricAggregation ): void { - const edgeCategoryClass = this.getEdgeCategoryClass(latencyMetric, errorPercentageMetric); - selection.classed(this.getAllCategoryClasses().join(' '), false).classed(edgeCategoryClass, true); + const edgeFocusedCategory = this.isEmphasizedOrFocused(visibility) + ? this.getEdgeFocusedCategory(primaryMetricCategory, secondaryMetricCategory) + : undefined; + + selection + .select(selector(this.edgeLineClass)) + .select('.edge-path') + .attr('stroke', edgeFocusedCategory?.strokeColor ?? Color.Gray3); + + selection + .select(selector(this.edgeMetricBubbleClass)) + .attr('fill', edgeFocusedCategory?.fillColor ?? '') + .attr('stroke', edgeFocusedCategory?.strokeColor ?? 'none'); selection .select(selector(this.edgeMetricValueClass)) - .text(this.getMetricValueString(latencyMetric, errorPercentageMetric)); + .text( + this.getMetricValueString( + primaryMetricAggregation, + secondaryMetricAggregation, + primaryMetricCategory, + secondaryMetricCategory + ) + ); selection .select(selector(this.edgeLineClass)) .attr( 'marker-end', - visibility === TopologyElementVisibility.Emphasized || visibility === TopologyElementVisibility.Focused - ? `url(#${this.getMarkerIdForCategory(edgeCategoryClass)})` + this.isEmphasizedOrFocused(visibility) + ? `url(#${this.getMarkerIdForCategory(edgeFocusedCategory?.getCategoryClassName() ?? '')})` : 'none' ); } @@ -143,22 +158,26 @@ export class EntityEdgeCurveRendererService implements TopologyEdgeRenderDelegat } private defineArrowMarkersIfNeeded(edgeElement: SVGGElement, domRenderer: Renderer2): void { + const allEdgeCategories = this.topologyDataSourceModelPropertiesService.getAllEdgeCategories(); this.d3Utils .select(this.svgUtils.addDefinitionDeclarationToSvgIfNotExists(edgeElement, domRenderer), domRenderer) .selectAll('marker') - .data(this.getAllCategoryClasses()) + .data(allEdgeCategories) .enter() .append('marker') - .attr('id', category => this.getMarkerIdForCategory(category)) + .attr('id', category => this.getMarkerIdForCategory(category.getCategoryClassName())) .attr('viewBox', '0 0 10 10') .attr('refX', 5) .attr('refY', 5) .attr('markerWidth', 12) .attr('markerHeight', 12) .attr('orient', 'auto-start-reverse') + .attr('fill', category => category.fillColor) .append('path') .classed(this.edgeArrowClass, true) - .each((category, index, elements) => this.d3Utils.select(elements[index], domRenderer).classed(category, true)) + .each((category, index, elements) => + this.d3Utils.select(elements[index], domRenderer).classed(category.getCategoryClassName(), true) + ) .attr('d', 'M2,2 L5,5 L2,8'); } @@ -227,44 +246,30 @@ export class EntityEdgeCurveRendererService implements TopologyEdgeRenderDelegat return 2; } - protected getAllCategoryClasses(): string[] { - return [ - ...allLatencyMetricCategories.map(getLatencyCategoryClass), - ...allErrorPercentageMetricCategories.map(getErrorPercentageCategoryClass) - ]; + private isEmphasizedOrFocused(visibility: TopologyElementVisibility): boolean { + return visibility === TopologyElementVisibility.Emphasized || visibility === TopologyElementVisibility.Focused; } - private getEdgeCategoryClass( - latencyMetric?: PercentileLatencyMetricAggregation, - errorPercentageMetric?: ErrorPercentageMetricAggregation - ): string { - if (errorPercentageMetric?.category === ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5) { - return getErrorPercentageCategoryClass(errorPercentageMetric?.category); - } - - if (latencyMetric) { - return getLatencyCategoryClass(latencyMetric.category); - } - - return ''; + private getEdgeFocusedCategory( + primaryMetricCategory?: TopologyMetricCategoryData, + secondaryMetricCategory?: TopologyMetricCategoryData + ): TopologyMetricCategoryData | undefined { + return secondaryMetricCategory?.highestPrecedence ? secondaryMetricCategory : primaryMetricCategory; } private getMetricValueString( - latencyMetric?: PercentileLatencyMetricAggregation, - errorPercentageMetric?: ErrorPercentageMetricAggregation + primaryMetricAggregation?: MetricAggregation, + secondaryMetricAggregation?: MetricAggregation, + primaryMetricCategory?: TopologyMetricCategoryData, + secondaryMetricCategory?: TopologyMetricCategoryData ): string { - if ( - errorPercentageMetric && - (errorPercentageMetric.category === ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 || !latencyMetric) - ) { - return this.formattedMetricValue(errorPercentageMetric.value, errorPercentageMetric.units); - } - - if (latencyMetric) { - return this.formattedMetricValue(latencyMetric.value, latencyMetric.units); + if (secondaryMetricAggregation && (secondaryMetricCategory?.highestPrecedence === true || !primaryMetricCategory)) { + return this.formattedMetricValue(secondaryMetricAggregation.value, secondaryMetricAggregation?.units); } - return '-'; + return primaryMetricAggregation + ? this.formattedMetricValue(primaryMetricAggregation.value, primaryMetricAggregation?.units) + : '-'; } private formattedMetricValue(valueToShow: number, unit?: string): string { diff --git a/projects/observability/src/shared/dashboard/widgets/topology/metric/metric-category.ts b/projects/observability/src/shared/dashboard/widgets/topology/metric/metric-category.ts deleted file mode 100644 index 243ed62f4..000000000 --- a/projects/observability/src/shared/dashboard/widgets/topology/metric/metric-category.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Dictionary } from '@hypertrace/common'; -import { MetricAggregation, MetricAggregationType } from '@hypertrace/distributed-tracing'; -import { - ErrorPercentageMetricAggregation, - ErrorPercentageMetricValueCategory -} from '../../../../graphql/model/schema/specifications/error-percentage-aggregation-specification'; -import { MetricAggregationSpecification } from '../../../../graphql/model/schema/specifications/metric-aggregation-specification'; -import { - PercentileLatencyMetricAggregation, - PercentileLatencyMetricValueCategory -} from '../../../../graphql/model/schema/specifications/percentile-latency-aggregation-specification'; - -export const getLatencyMetric = ( - data: Dictionary, - metricSpecifications?: MetricAggregationSpecification[] -): PercentileLatencyMetricAggregation | undefined => { - const latencySpecification = metricSpecifications?.find( - specification => specification.name === 'p99Latency' && specification.aggregation === MetricAggregationType.P99 - ); - - return getTopologyMetric(data, latencySpecification); -}; - -export const getErrorPercentageMetric = ( - data: Dictionary, - metricSpecifications?: MetricAggregationSpecification[] -): ErrorPercentageMetricAggregation | undefined => { - const errorSpecification = metricSpecifications?.find( - specification => - specification.name === 'errorPercentage' && specification.aggregation === MetricAggregationType.Average - ); - - return getTopologyMetric(data, errorSpecification); -}; - -export const getAllCategoryClasses = () => [ - ...allLatencyMetricCategories.map(getLatencyCategoryClass), - ...allErrorPercentageMetricCategories.map(getErrorPercentageCategoryClass) -]; - -export const getLatencyCategoryClass = (category?: PercentileLatencyMetricValueCategory): string => - category !== undefined ? `${category}-category` : ''; - -export const getErrorPercentageCategoryClass = (category?: ErrorPercentageMetricValueCategory): string => - category !== undefined ? `${category}-error-category` : ''; - -export const allLatencyMetricCategories = [ - PercentileLatencyMetricValueCategory.LessThan20, - PercentileLatencyMetricValueCategory.From20To100, - PercentileLatencyMetricValueCategory.From100To500, - PercentileLatencyMetricValueCategory.From500To1000, - PercentileLatencyMetricValueCategory.GreaterThanOrEqualTo1000, - PercentileLatencyMetricValueCategory.NotSpecified -]; - -export const allErrorPercentageMetricCategories = [ - ErrorPercentageMetricValueCategory.LessThan5, - ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5, - ErrorPercentageMetricValueCategory.NotSpecified -]; - -const getTopologyMetric = ( - data: Dictionary, - spec?: MetricAggregationSpecification -): T | undefined => { - if (!spec) { - return undefined; - } - - return data[spec.resultAlias()] as T; -}; diff --git a/projects/observability/src/shared/dashboard/widgets/topology/node/box/api-node-renderer/api-node-box-renderer.service.ts b/projects/observability/src/shared/dashboard/widgets/topology/node/box/api-node-renderer/api-node-box-renderer.service.ts index e1bae7654..81f682d0a 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/node/box/api-node-renderer/api-node-box-renderer.service.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/node/box/api-node-renderer/api-node-box-renderer.service.ts @@ -5,7 +5,7 @@ import { entityTypeKey, ObservabilityEntityType } from '../../../../../../graphq import { EntityNode } from '../../../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; import { EntityNodeBoxRendererService } from '../entity-node-box-renderer.service'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class ApiNodeBoxRendererService extends EntityNodeBoxRendererService { public matches(node: TopologyNode & Partial): node is EntityNode { return this.isEntityNode(node) && node.data[entityTypeKey] === ObservabilityEntityType.Api; diff --git a/projects/observability/src/shared/dashboard/widgets/topology/node/box/backend-node-renderer/backend-node-box-renderer.service.ts b/projects/observability/src/shared/dashboard/widgets/topology/node/box/backend-node-renderer/backend-node-box-renderer.service.ts index 0108d435e..012b31139 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/node/box/backend-node-renderer/backend-node-box-renderer.service.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/node/box/backend-node-renderer/backend-node-box-renderer.service.ts @@ -5,7 +5,7 @@ import { entityTypeKey, ObservabilityEntityType } from '../../../../../../graphq import { EntityNode } from '../../../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; import { EntityNodeBoxRendererService } from '../entity-node-box-renderer.service'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class BackendNodeBoxRendererService extends EntityNodeBoxRendererService { public matches(node: TopologyNode & Partial): node is EntityNode { return this.isEntityNode(node) && node.data[entityTypeKey] === ObservabilityEntityType.Backend; diff --git a/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.scss b/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.scss index 241c595c7..1539df693 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.scss +++ b/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.scss @@ -45,7 +45,6 @@ .entity-outer-band { filter: url(#entity-node-dropshadow-filter); fill: white; - stroke: $blue-4; stroke-width: 1px; } @@ -86,49 +85,6 @@ color: $gray-4; fill: currentColor; } - - &.less-than-20-category { - .metric-category { - fill: $blue-gray-1; - } - } - - &.from-20-to-100-category { - .metric-category { - fill: $blue-gray-2; - } - } - - &.from-100-to-500-category { - .metric-category { - fill: $blue-gray-3; - } - } - - &.from-500-to-1000-category { - .metric-category { - fill: $blue-gray-4; - } - } - - &.greater-than-or-equal-to-1000-category { - .metric-category { - fill: $blue-gray-5; - } - } - - &.not-specified-category { - .metric-category { - fill: lightgray; - } - } - - &.greater-than-or-equal-to-5-error-category { - .entity-outer-band { - fill: $red-1; - stroke: $red-5; - } - } } } } diff --git a/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.service.ts b/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.service.ts index d484c6de9..209392247 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.service.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/node/box/entity-node-box-renderer.service.ts @@ -1,32 +1,32 @@ import { Injectable, Renderer2 } from '@angular/core'; -import { DomElementMeasurerService, NumericFormatter, selector } from '@hypertrace/common'; +import { Color, DomElementMeasurerService, NumericFormatter, selector } from '@hypertrace/common'; import { MetricAggregation, MetricHealth } from '@hypertrace/distributed-tracing'; import { select, Selection } from 'd3-selection'; import { Observable, Subject } from 'rxjs'; import { filter, takeUntil } from 'rxjs/operators'; import { TopologyNodeRendererDelegate } from '../../../../../components/topology/renderers/node/topology-node-renderer.service'; -import { TopologyCoordinates, TopologyNode, TopologyNodeState } from '../../../../../components/topology/topology'; +import { + TopologyCoordinates, + TopologyElementVisibility, + TopologyNode, + TopologyNodeState +} from '../../../../../components/topology/topology'; import { D3UtilService } from '../../../../../components/utils/d3/d3-util.service'; import { SvgUtilService } from '../../../../../components/utils/svg/svg-util.service'; import { Entity } from '../../../../../graphql/model/schema/entity'; -import { ErrorPercentageMetricAggregation } from '../../../../../graphql/model/schema/specifications/error-percentage-aggregation-specification'; import { MetricAggregationSpecification } from '../../../../../graphql/model/schema/specifications/metric-aggregation-specification'; -import { PercentileLatencyMetricAggregation } from '../../../../../graphql/model/schema/specifications/percentile-latency-aggregation-specification'; import { EntityNode } from '../../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; import { EntityIconLookupService } from '../../../../../services/entity/entity-icon-lookup.service'; import { EntityNavigationService } from '../../../../../services/navigation/entity/entity-navigation.service'; -import { - getAllCategoryClasses, - getErrorPercentageCategoryClass, - getErrorPercentageMetric, - getLatencyCategoryClass, - getLatencyMetric -} from '../../metric/metric-category'; +import { TopologyMetricCategoryData } from '../../../../data/graphql/topology/metrics/topology-metric-category.model'; +import { TopologyDataSourceModelPropertiesService } from '../../topology-data-source-model-properties.service'; import { VisibilityUpdater } from '../../visibility-updater'; @Injectable() // Annotate so parameters still provide metadata for children export abstract class EntityNodeBoxRendererService implements TopologyNodeRendererDelegate { private readonly entityMetricClass: string = 'entity-metric-value'; + private readonly entityOuterBandClass: string = 'entity-outer-band'; + private readonly metricCategoryClass: string = 'metric-category'; private readonly dropshadowFilterId: string = 'entity-node-dropshadow-filter'; private readonly nodeSelectionMap: WeakMap< EntityNode, @@ -41,7 +41,8 @@ export abstract class EntityNodeBoxRendererService implements TopologyNodeRender private readonly domElementMeasurerService: DomElementMeasurerService, private readonly svgUtils: SvgUtilService, protected readonly d3Utils: D3UtilService, - private readonly entityIconLookupService: EntityIconLookupService + private readonly entityIconLookupService: EntityIconLookupService, + private readonly topologyDataSourceModelPropertiesService: TopologyDataSourceModelPropertiesService ) {} public abstract matches(node: TopologyNode & Partial): node is EntityNode; @@ -101,13 +102,14 @@ export abstract class EntityNodeBoxRendererService implements TopologyNodeRender domElementRenderer: Renderer2 ): void { const elementSelection = this.d3Utils.select(element, domElementRenderer); - const metricSpecifications = state.dataSpecifiers?.map(specifier => specifier.value); - - this.updateNodeMetric( - elementSelection, - getLatencyMetric(node.data, metricSpecifications), - getErrorPercentageMetric(node.data, metricSpecifications) - ); + const primaryMetricCategory = this.topologyDataSourceModelPropertiesService + .getPrimaryNodeMetric() + ?.extractAndGetDataCategoryForMetric(node.data); + const secondaryMetricCategory = this.topologyDataSourceModelPropertiesService + .getSecondaryNodeMetric() + ?.extractAndGetDataCategoryForMetric(node.data); + + this.updateNodeMetric(elementSelection, state.visibility, primaryMetricCategory, secondaryMetricCategory); this.visibilityUpdater.updateVisibility(elementSelection, state.visibility); elementSelection.classed('dragging', state.dragging); @@ -127,14 +129,35 @@ export abstract class EntityNodeBoxRendererService implements TopologyNodeRender protected updateNodeMetric( selection: Selection, - latencyMetric?: PercentileLatencyMetricAggregation, - errorPercentageMetric?: ErrorPercentageMetricAggregation + visibility: TopologyElementVisibility, + primaryMetric?: TopologyMetricCategoryData, + secondaryMetric?: TopologyMetricCategoryData ): void { selection - .classed(getAllCategoryClasses().join(' '), false) - .classed(getLatencyCategoryClass(latencyMetric?.category), true) - .classed(getErrorPercentageCategoryClass(errorPercentageMetric?.category), true) + .classed(primaryMetric?.getCategoryClassName() ?? '', true) + .classed(secondaryMetric?.getCategoryClassName() ?? '', true) .select(selector(this.entityMetricClass)); + + // For primary category + selection.select(selector(this.metricCategoryClass)).attr('fill', primaryMetric?.fillColor!); + + // For secondary category + selection + .select(selector(this.entityOuterBandClass)) + .style('fill', () => { + if (visibility === TopologyElementVisibility.Focused || visibility === TopologyElementVisibility.Emphasized) { + return this.focusedOrEmphasizedColor(); + } + + return secondaryMetric?.fillColor ?? ''; + }) + .style('stroke', () => { + if (visibility === TopologyElementVisibility.Focused) { + return secondaryMetric?.highestPrecedence === true ? secondaryMetric?.focusColor : primaryMetric?.focusColor!; + } + + return secondaryMetric?.strokeColor ?? ''; + }); } protected isEntityNode(node: TopologyNode & Partial): node is EntityNode { @@ -155,7 +178,7 @@ export abstract class EntityNodeBoxRendererService implements TopologyNodeRender protected addOuterBand(nodeSelection: Selection): void { nodeSelection .append('rect') - .classed('entity-outer-band', true) + .classed(this.entityOuterBandClass, true) .attr('x', 0) .attr('y', 0) .attr('width', this.boxWidth()) @@ -168,7 +191,7 @@ export abstract class EntityNodeBoxRendererService implements TopologyNodeRender protected addMetricCategory(nodeSelection: Selection): void { nodeSelection .append('circle') - .classed('metric-category', true) + .classed(this.metricCategoryClass, true) .attr('transform', `translate(${this.getPadding()}, ${(this.boxHeight() - this.metricCategoryWidth()) / 2})`) .attr('cx', '4') .attr('cy', '4') @@ -246,6 +269,14 @@ export abstract class EntityNodeBoxRendererService implements TopologyNodeRender .each((_datum, index, groups) => this.svgUtils.truncateText(groups[index], 30)); } + protected focusedOrEmphasizedColor(): string { + return Color.White; + } + + protected focusedBandColor(): string { + return Color.Blue4; + } + protected boxWidth(): number { return 240; } diff --git a/projects/observability/src/shared/dashboard/widgets/topology/node/box/service-node-renderer/service-node-box-renderer.service.ts b/projects/observability/src/shared/dashboard/widgets/topology/node/box/service-node-renderer/service-node-box-renderer.service.ts index d99ed8471..02955ed56 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/node/box/service-node-renderer/service-node-box-renderer.service.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/node/box/service-node-renderer/service-node-box-renderer.service.ts @@ -5,7 +5,7 @@ import { entityTypeKey, ObservabilityEntityType } from '../../../../../../graphq import { EntityNode } from '../../../../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; import { EntityNodeBoxRendererService } from '../entity-node-box-renderer.service'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class ServiceNodeBoxRendererService extends EntityNodeBoxRendererService { public matches(node: TopologyNode & Partial): node is EntityNode { return this.isEntityNode(node) && node.data[entityTypeKey] === ObservabilityEntityType.Service; diff --git a/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.test.ts b/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.test.ts index a475b3a7f..fa56182a6 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.test.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.test.ts @@ -3,9 +3,10 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Renderer2 } from '@angular/core'; import { discardPeriodicTasks, fakeAsync, flush, TestBed } from '@angular/core/testing'; import { IconLibraryTestingModule, IconRegistryService } from '@hypertrace/assets-library'; -import { DomElementMeasurerService, selector } from '@hypertrace/common'; +import { Color, DomElementMeasurerService, selector } from '@hypertrace/common'; import { mockDashboardWidgetProviders } from '@hypertrace/dashboards/testing'; import { MetricAggregationType, MetricHealth } from '@hypertrace/distributed-tracing'; +import { MetricAggregationSpecificationModel } from '@hypertrace/observability'; import { addWidthAndHeightToSvgElForTest } from '@hypertrace/test-utils'; import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { uniq } from 'lodash-es'; @@ -23,6 +24,9 @@ import { } from '../../../graphql/request/handlers/entities/query/topology/entity-topology-graphql-query-handler.service'; import { ObservabilityIconLibraryModule } from '../../../icons/observability-icon-library.module'; import { ObservabilityIconType } from '../../../icons/observability-icon-type'; +import { TopologyMetricCategoryModel } from '../../data/graphql/topology/metrics/topology-metric-category.model'; +import { TopologyMetricWithCategoryModel } from '../../data/graphql/topology/metrics/topology-metric-with-category.model'; +import { TopologyMetricsModel } from './../../data/graphql/topology/metrics/topology-metrics.model'; import { BackendNodeBoxRendererService } from './node/box/backend-node-renderer/backend-node-box-renderer.service'; import { TopologyWidgetRendererComponent } from './topology-widget-renderer.component'; import { TopologyWidgetModule } from './topology-widget.module'; @@ -30,22 +34,70 @@ import { TopologyWidgetModule } from './topology-widget.module'; describe('Topology Widget renderer', () => { let mockResponse: EntityNode[] = []; const specBuilder = new ObservabilitySpecificationBuilder(); - const nodeSpec = { - titleSpecification: specBuilder.attributeSpecificationForKey('name'), - metricSpecifications: [ - specBuilder.metricAggregationSpecForKey('metric1', MetricAggregationType.Average), - specBuilder.metricAggregationSpecForKey('metric2', MetricAggregationType.Max), - specBuilder.metricAggregationSpecForLatency(MetricAggregationType.P99, 'p99Latency'), - specBuilder.metricAggregationSpecForErrorPercentage(MetricAggregationType.Average) - ] + + const createCategoryModel = ( + name: string, + minValue: number, + fillColor: Color, + strokeColor: Color, + focusColor: Color, + maxValue?: number + ): TopologyMetricCategoryModel => { + const categoryModel = new TopologyMetricCategoryModel(); + categoryModel.name = name; + categoryModel.minValue = minValue; + categoryModel.maxValue = maxValue; + categoryModel.fillColor = fillColor; + categoryModel.strokeColor = strokeColor; + categoryModel.focusColor = focusColor; + + return categoryModel; }; - const edgeSpec = { - metricSpecifications: [ - specBuilder.metricAggregationSpecForKey('metric3', MetricAggregationType.Sum), - specBuilder.metricAggregationSpecForKey('metric4', MetricAggregationType.Min), - specBuilder.metricAggregationSpecForLatency(MetricAggregationType.P99, 'p99Latency'), - specBuilder.metricAggregationSpecForErrorPercentage(MetricAggregationType.Average) - ] + + const createSpecificationModel = (metric: string, aggregation: MetricAggregationType) => { + const specification = new MetricAggregationSpecificationModel(); + specification.metric = metric; + specification.aggregation = aggregation; + + specification.modelOnInit(); + + return specification; + }; + + const createMetricWithCategory = ( + spec: MetricAggregationSpecificationModel, + categories: TopologyMetricCategoryModel[] + ) => { + const model = new TopologyMetricWithCategoryModel(); + model.specification = spec; + model.categories = categories; + + return model; + }; + + const createTopologyMetricsModel = (prefix: string) => { + const primary = createMetricWithCategory( + createSpecificationModel(`${prefix}-metric-1`, MetricAggregationType.Average), + [createCategoryModel(`${prefix}-first-1`, 0, Color.Blue2, Color.Blue3, Color.Blue4, 10)] + ); + + const secondary = createMetricWithCategory( + createSpecificationModel(`${prefix}-metric-2`, MetricAggregationType.Average), + [createCategoryModel(`${prefix}-first-2`, 0, Color.Blue2, Color.Blue3, Color.Blue4, 10)] + ); + + const others = [ + createMetricWithCategory(createSpecificationModel(`${prefix}-metric-3`, MetricAggregationType.Average), [ + createCategoryModel(`${prefix}-others-2`, 0, Color.Blue2, Color.Blue3, Color.Blue4, 10) + ]) + ]; + + const metricsModel: TopologyMetricsModel = new TopologyMetricsModel(); + metricsModel.primary = primary; + metricsModel.secondary = secondary; + metricsModel.others = others; + + return metricsModel; }; const findNodeWithTypeAndName = ( @@ -75,13 +127,33 @@ describe('Topology Widget renderer', () => { return node!; }; + const getSpecifications = (metricsModel: TopologyMetricsModel) => + [ + metricsModel.primary.specification, + metricsModel.secondary?.specification ?? [], + (metricsModel.others ?? []).map(model => model.specification) + ].flat(); + + const nodeMetrics = createTopologyMetricsModel('topo'); + const edgeMetrics = createTopologyMetricsModel('topo'); + + const nodeSpec = { + titleSpecification: specBuilder.attributeSpecificationForKey('name'), + metricSpecifications: getSpecifications(nodeMetrics) + }; + const edgeSpec = { + metricSpecifications: getSpecifications(edgeMetrics) + }; + const mockModel = { getData: jest.fn(() => of({ nodes: mockResponse, nodeSpecification: nodeSpec, edgeSpecification: edgeSpec, - nodeTypes: uniq(mockResponse.map(node => node.data[entityTypeKey])) + nodeTypes: uniq(mockResponse.map(node => node.data[entityTypeKey])), + nodeMetrics: nodeMetrics, + edgeMetrics: edgeMetrics }) ), showLegend: true @@ -154,7 +226,7 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '1', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 1', - 'avg(metric1)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.Healthy } @@ -167,7 +239,7 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '2', [entityTypeKey]: ObservabilityEntityType.Api, name: 'Api 2', - 'avg(metric1)': { + 'avg(topo-metric-1)': { value: 456, health: MetricHealth.Warning } @@ -179,7 +251,11 @@ describe('Topology Widget renderer', () => { data: { [entityIdKey]: '3', [entityTypeKey]: ObservabilityEntityType.Backend, - name: 'Backend 3' + name: 'Backend 3', + 'avg(topo-metric-3)': { + value: 456, + health: MetricHealth.Warning + } }, specification: nodeSpec } @@ -211,17 +287,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '1', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 1', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -238,16 +314,16 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '2', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 2', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -264,16 +340,16 @@ describe('Topology Widget renderer', () => { fromNode: mockResponse[0], toNode: mockResponse[1], data: { - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'sum(metric3)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -307,17 +383,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '1', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 1', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -334,17 +410,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '2', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 2', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -361,17 +437,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '3', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 3', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -388,16 +464,16 @@ describe('Topology Widget renderer', () => { fromNode: mockResponse[0], toNode: mockResponse[1], data: { - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 0, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.LessThan5 }, - 'sum(metric3)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -415,16 +491,16 @@ describe('Topology Widget renderer', () => { fromNode: mockResponse[1], toNode: mockResponse[2], data: { - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 1, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.LessThan5 }, - 'sum(metric3)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -487,17 +563,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '1', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 1', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -514,22 +590,49 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '2', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 2', - 'p99(duration)': { - value: 234, + 'avg(topo-metric-1)': { + value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { - value: 456, + 'avg(topo-metric-2)': { + value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { - value: 567, + 'avg(topo-metric-3)': { + value: 345, health: MetricHealth.NotSpecified }, 'max(metric2)': { - value: 789, + value: 456, + health: MetricHealth.NotSpecified + } + }, + specification: nodeSpec + }, + { + edges: [], + data: { + [entityIdKey]: '3', + [entityTypeKey]: ObservabilityEntityType.Service, + name: 'Service 3', + 'avg(topo-metric-1)': { + value: 123, + health: MetricHealth.NotSpecified, + category: PercentileLatencyMetricValueCategory.From100To500 + }, + 'avg(topo-metric-2)': { + value: 234, + health: MetricHealth.NotSpecified, + category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 + }, + 'avg(topo-metric-3)': { + value: 345, + health: MetricHealth.NotSpecified + }, + 'avg(metric2)': { + value: 456, health: MetricHealth.NotSpecified } }, @@ -541,16 +644,16 @@ describe('Topology Widget renderer', () => { fromNode: mockResponse[0], toNode: mockResponse[1], data: { - 'p99(duration)': { - value: 321, + 'avg(topo-metric-1)': { + value: 0, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.LessThan5 }, - 'sum(metric3)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -564,12 +667,40 @@ describe('Topology Widget renderer', () => { mockResponse[0].edges.push(edge0To1); mockResponse[1].edges.push(edge0To1); + const edge1To2: EntityEdge = { + fromNode: mockResponse[1], + toNode: mockResponse[2], + data: { + 'avg(topo-metric-1)': { + value: 1, + health: MetricHealth.NotSpecified + }, + 'avg(topo-metric-2)': { + value: 234, + health: MetricHealth.NotSpecified, + category: ErrorPercentageMetricValueCategory.LessThan5 + }, + 'avg(topo-metric-3)': { + value: 345, + health: MetricHealth.NotSpecified + }, + 'min(metric4)': { + value: 456, + health: MetricHealth.NotSpecified + } + }, + specification: edgeSpec + }; + + mockResponse[1].edges.push(edge1To2); + mockResponse[2].edges.push(edge1To2); + const spectator = createComponent(); spectator.tick(); // Can't use normal angular querying against svgs const getService = findNodeWithTypeAndName.bind(undefined, spectator, ObservabilityEntityType.Service, 'Service 1'); - const getEdge = findEdgeWithMetricValue.bind(undefined, spectator, 321); + const getEdge = findEdgeWithMetricValue.bind(undefined, spectator, 0); spectator.dispatchMouseEvent(getService(), 'mouseenter'); spectator.tick(500); // Trigger popup @@ -582,12 +713,15 @@ describe('Topology Widget renderer', () => { }); let metricRowElements = container.querySelectorAll('.metric-row'); - expect(metricRowElements.length).toBe(4); - expect(metricRowElements[0].querySelector('.metric-label')).toContainText('Metric1'); - expect(metricRowElements[0].querySelector('.metric-value')).toContainText('345'); + expect(metricRowElements.length).toBe(3); + expect(metricRowElements[0].querySelector('.metric-label')).toContainText('Topo-metric-1'); + expect(metricRowElements[0].querySelector('.metric-value')).toContainText('123'); - expect(metricRowElements[1].querySelector('.metric-label')).toContainText('Max. Metric2'); - expect(metricRowElements[1].querySelector('.metric-value')).toContainText('456'); + expect(metricRowElements[1].querySelector('.metric-label')).toContainText('Topo-metric-2'); + expect(metricRowElements[1].querySelector('.metric-value')).toContainText('234'); + + expect(metricRowElements[2].querySelector('.metric-label')).toContainText('Topo-metric-3'); + expect(metricRowElements[2].querySelector('.metric-value')).toContainText('345'); spectator.dispatchMouseEvent(getService(), 'mouseleave'); expect(spectator.query('.tooltip-container', { root: true })).not.toExist(); @@ -601,11 +735,11 @@ describe('Topology Widget renderer', () => { text: `Service 1Service 2` }); metricRowElements = container.querySelectorAll('.metric-row'); - expect(metricRowElements[0].querySelector('.metric-label')).toContainText('Sum Metric3'); - expect(metricRowElements[0].querySelector('.metric-value')).toContainText('345'); + expect(metricRowElements[0].querySelector('.metric-label')).toContainText('Topo-metric-1'); + expect(metricRowElements[0].querySelector('.metric-value')).toContainText('0'); - expect(metricRowElements[1].querySelector('.metric-label')).toContainText('Min. Metric4'); - expect(metricRowElements[1].querySelector('.metric-value')).toContainText('456'); + expect(metricRowElements[1].querySelector('.metric-label')).toContainText('Topo-metric-2'); + expect(metricRowElements[1].querySelector('.metric-value')).toContainText('234'); spectator.dispatchMouseEvent(getEdge(), 'mouseleave'); expect(spectator.query('.tooltip-container', { root: true })).not.toExist(); @@ -619,17 +753,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '1', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 1', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -646,16 +780,16 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '2', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 2', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -672,16 +806,16 @@ describe('Topology Widget renderer', () => { fromNode: mockResponse[0], toNode: mockResponse[1], data: { - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.LessThan5 }, - 'sum(metric3)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -700,7 +834,7 @@ describe('Topology Widget renderer', () => { // Can't use normal angular querying against svgs const getService = findNodeWithTypeAndName.bind(undefined, spectator, ObservabilityEntityType.Service, 'Service 1'); - const getEdge = findEdgeWithMetricValue.bind(undefined, spectator, 123); + const getEdge = findEdgeWithMetricValue.bind(undefined, spectator, 234); const getCloseButton = () => spectator.query('.hide-tooltip-button', { root: true })!; const getTooltip = () => spectator.query('.tooltip-container', { root: true })!; @@ -735,17 +869,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '1', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 1', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 123, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 234, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 345, health: MetricHealth.NotSpecified }, @@ -762,17 +896,17 @@ describe('Topology Widget renderer', () => { [entityIdKey]: '2', [entityTypeKey]: ObservabilityEntityType.Service, name: 'Service 2', - 'p99(duration)': { + 'avg(topo-metric-1)': { value: 234, health: MetricHealth.NotSpecified, category: PercentileLatencyMetricValueCategory.From100To500 }, - 'avg(errorCount)_avg(numCalls)': { + 'avg(topo-metric-2)': { value: 456, health: MetricHealth.NotSpecified, category: ErrorPercentageMetricValueCategory.GreaterThanOrEqualTo5 }, - 'avg(metric1)': { + 'avg(topo-metric-3)': { value: 567, health: MetricHealth.NotSpecified }, diff --git a/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.ts b/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.ts index 51ecb7cb5..28303feb8 100644 --- a/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.ts +++ b/projects/observability/src/shared/dashboard/widgets/topology/topology-widget-renderer.component.ts @@ -20,6 +20,7 @@ import { ApiNodeBoxRendererService } from './node/box/api-node-renderer/api-node import { BackendNodeBoxRendererService } from './node/box/backend-node-renderer/backend-node-box-renderer.service'; import { ServiceNodeBoxRendererService } from './node/box/service-node-renderer/service-node-box-renderer.service'; import { TopologyEntityTooltipComponent } from './tooltip/topology-entity-tooltip.component'; +import { TopologyDataSourceModelPropertiesService } from './topology-data-source-model-properties.service'; import { TopologyWidgetModel } from './topology-widget.model'; @Renderer({ modelClass: TopologyWidgetModel }) @@ -30,7 +31,16 @@ import { TopologyWidgetModel } from './topology-widget.model'; './edge/curved/entity-edge-curve-renderer.scss', './topology-widget-renderer.component.scss' ], - providers: [TopologyNodeRendererService, TopologyEdgeRendererService, TopologyTooltipRendererService], + providers: [ + TopologyNodeRendererService, + TopologyEdgeRendererService, + TopologyTooltipRendererService, + EntityEdgeCurveRendererService, + ApiNodeBoxRendererService, + BackendNodeBoxRendererService, + ServiceNodeBoxRendererService, + TopologyDataSourceModelPropertiesService + ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -81,6 +91,7 @@ export class TopologyWidgetRendererComponent extends WidgetRenderer ({