Skip to content

refactor: breadcrumb to support additional specifications #1254

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Nov 12, 2021
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"build:components": "ng build components",
"build:dashboards": "ng build dashboards",
"build:ci": "node --max_old_space_size=3584 node_modules/@angular/cli/bin/ng build --configuration production --no-progress",
"test": "ng test hypertrace-ui --cache",
"test": "ng test hypertrace-ui --cache --maxWorkers=2",
"lint": "ng lint hypertrace-ui",
"lint:fix": "ng lint --fix hypertrace-ui",
"prettier:check": "prettier --check '**'",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { NavigationService } from '@hypertrace/common';
import { GraphQlRequestCacheability, GraphQlRequestService } from '@hypertrace/graphql-client';
import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
import { EntityBreadcrumb } from './../../../shared/services/entity-breadcrumb/entity-breadcrumb.resolver';

import { patchRouterNavigateForTest, runFakeRxjs } from '@hypertrace/test-utils';
import { of } from 'rxjs';
Expand All @@ -14,7 +15,7 @@ import { ObservabilityIconType } from '../../../shared/icons/observability-icon-
import { ApiDetailBreadcrumbResolver } from './api-detail-breadcrumb.resolver';

describe('Api detail breadcrumb resolver', () => {
let spectator: SpectatorService<ApiDetailBreadcrumbResolver>;
let spectator: SpectatorService<ApiDetailBreadcrumbResolver<EntityBreadcrumb>>;
let activatedRouteSnapshot: ActivatedRouteSnapshot;
const buildResolver = createServiceFactory({
service: ApiDetailBreadcrumbResolver,
Expand Down Expand Up @@ -83,6 +84,8 @@ describe('Api detail breadcrumb resolver', () => {
runFakeRxjs(({ expectObservable }) => {
expectObservable(breadcrumb$).toBe('(abc|)', {
a: {
[entityIdKey]: 'test-service-id',
[entityTypeKey]: ObservabilityEntityType.Service,
label: 'test service',
icon: ObservabilityIconType.Service,
url: ['services', 'service', 'test-service-id']
Expand All @@ -93,9 +96,16 @@ describe('Api detail breadcrumb resolver', () => {
url: ['services', 'service', 'test-service-id', 'endpoints']
},
c: {
[entityIdKey]: 'test-id',
[entityTypeKey]: ObservabilityEntityType.Api,
label: 'test api',
icon: ObservabilityIconType.Api,
url: ['api', 'test-id']
url: ['api', 'test-id'],
name: 'test api',
parentId: 'test-service-id',
parentName: 'test service',
serviceName: 'test service',
serviceId: 'test-service-id'
}
});
});
Expand All @@ -108,7 +118,7 @@ describe('Api detail breadcrumb resolver', () => {
entityType: ObservabilityEntityType.Api,
id: 'test-id'
}),
{ cacheability: GraphQlRequestCacheability.NotCacheable }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to keep an eye on this change. Hopefully it doesn't break any existing case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could change the default from cacheable to undefined too - use the default options.

{ cacheability: GraphQlRequestCacheability.Cacheable }
);
}));

Expand All @@ -122,9 +132,14 @@ describe('Api detail breadcrumb resolver', () => {
runFakeRxjs(({ expectObservable }) => {
expectObservable(breadcrumb$).toBe('(y|)', {
y: {
[entityIdKey]: 'test-id',
[entityTypeKey]: ObservabilityEntityType.Api,
label: 'test api',
icon: ObservabilityIconType.Api,
url: ['api', 'test-id']
url: ['api', 'test-id'],
name: 'test api',
serviceName: 'test service',
serviceId: 'test-service-id'
}
});
});
Expand All @@ -137,7 +152,7 @@ describe('Api detail breadcrumb resolver', () => {
entityType: ObservabilityEntityType.Api,
id: 'test-id'
}),
{ cacheability: GraphQlRequestCacheability.NotCacheable }
{ cacheability: GraphQlRequestCacheability.Cacheable }
);
}));
});
Original file line number Diff line number Diff line change
@@ -1,101 +1,86 @@
import { Inject, Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import { ActivatedRouteSnapshot } from '@angular/router';
import { Breadcrumb, NavigationService, TimeRangeService } from '@hypertrace/common';
import { BreadcrumbsService } from '@hypertrace/components';
import { GraphQlRequestCacheability, GraphQlRequestService } from '@hypertrace/graphql-client';
import { GraphQlRequestService } from '@hypertrace/graphql-client';
import { entityIdKey } from '@hypertrace/observability';
import { Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { map, switchMap } from 'rxjs/operators';
import { EntityMetadata, EntityMetadataMap, ENTITY_METADATA } from '../../../shared/constants/entity-metadata';
import { Entity, ObservabilityEntityType } from '../../../shared/graphql/model/schema/entity';
import { GraphQlTimeRange } from '../../../shared/graphql/model/schema/timerange/graphql-time-range';
import { SpecificationBuilder } from '../../../shared/graphql/request/builders/specification/specification-builder';
import { Entity, entityTypeKey, ObservabilityEntityType } from '../../../shared/graphql/model/schema/entity';
import {
EntityGraphQlQueryHandlerService,
ENTITY_GQL_REQUEST
} from '../../../shared/graphql/request/handlers/entities/query/entity/entity-graphql-query-handler.service';
EntityBreadcrumb,
EntityBreadcrumbResolver
} from '../../../shared/services/entity-breadcrumb/entity-breadcrumb.resolver';
import { EntityIconLookupService } from './../../../shared/services/entity/entity-icon-lookup.service';

@Injectable({ providedIn: 'root' })
export class ApiDetailBreadcrumbResolver implements Resolve<Observable<Breadcrumb>> {
private readonly specificationBuilder: SpecificationBuilder = new SpecificationBuilder();
export class ApiDetailBreadcrumbResolver<T extends EntityBreadcrumb> extends EntityBreadcrumbResolver<T> {
protected readonly apiEntityMetadata: EntityMetadata | undefined;

public constructor(
timeRangeService: TimeRangeService,
graphQlQueryService: GraphQlRequestService,
iconLookupService: EntityIconLookupService,
private readonly navigationService: NavigationService,
private readonly timeRangeService: TimeRangeService,
private readonly graphQlQueryService: GraphQlRequestService,
protected readonly breadcrumbService: BreadcrumbsService,
@Inject(ENTITY_METADATA) private readonly entityMetadataMap: EntityMetadataMap
) {
super(timeRangeService, graphQlQueryService, iconLookupService);
this.apiEntityMetadata = this.entityMetadataMap.get(ObservabilityEntityType.Api);
}

public async resolve(activatedRouteSnapshot: ActivatedRouteSnapshot): Promise<Observable<Breadcrumb>> {
const id = activatedRouteSnapshot.paramMap.get('id') as string;
const parentType = this.resolveParentType();
const parentEntityMetadata = this.resolveParentType();

return Promise.resolve(
this.fetchEntity(id, parentType).pipe(
take(1),
this.fetchEntity(id, ObservabilityEntityType.Api).pipe(
map(apiEntity => ({
...apiEntity,
...this.getParentPartial(apiEntity, parentEntityMetadata)
})),
switchMap(api => [
...this.getParentBreadcrumbs(api, parentType),
...this.getParentBreadcrumbs(api, parentEntityMetadata),
this.createBreadcrumbForEntity(api, activatedRouteSnapshot)
])
)
);
}

protected createBreadcrumbForEntity(
api: ApiBreadcrumbDetails,
activatedRouteSnapshot: ActivatedRouteSnapshot
): Breadcrumb {
protected createBreadcrumbForEntity(api: Entity, activatedRouteSnapshot: ActivatedRouteSnapshot): EntityBreadcrumb {
return {
label: api.name,
...api,
label: api.name as string,
icon: this.apiEntityMetadata?.icon,
url: this.breadcrumbService.getPath(activatedRouteSnapshot)
};
}

protected getParentBreadcrumbs(api: ApiBreadcrumbDetails, parentEntityMetadata?: EntityMetadata): Breadcrumb[] {
protected getParentBreadcrumbs(
api: EntityBreadcrumb,
parentEntityMetadata?: EntityMetadata
): (EntityBreadcrumb | Breadcrumb)[] {
return parentEntityMetadata !== undefined
? [
{
label: api.parentName,
[entityIdKey]: api.parentId as string,
[entityTypeKey]: parentEntityMetadata.entityType,
label: api.parentName as string,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

types are wrong here, shouldn't need to cast

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ts is not able to detect the types in the pipe -> map operator

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the type on line 42? Are we sure we're not just dropping it on 59? That map should produce a T & Partial<Pick<ApiBreadcrumbDetails, 'parentName' | 'parentId'>> since we're spreading them together (that partial could use a type alias!).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit btw)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I am not able to make it work. IMO, getParentPartial is making the parentName and parentId optional and then we are required fields in ApiBreadcrumbDetails.

We wouldn't have to cast if we can ensure that following code returns an ApiBreadcrumbDetails object

 map(apiEntity => ({
          ...apiEntity,
          ...this.getParentPartial(apiEntity, parentEntityMetadata)
        })),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do that by assigning '' instead of undefined but i don't know if it would create any run time issue. So, I am ignoring it for this change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could just make those two fields optional on ApiBreadcrumbDetails I think? They are optional, they're omitted if we can't resolve them, hence the check on line 60 before we access them.

icon: parentEntityMetadata?.icon,
url: parentEntityMetadata?.detailPath(api.parentId)
url: parentEntityMetadata?.detailPath(api.parentId as string)
},
{
label: 'Endpoints',
icon: this.apiEntityMetadata?.icon,
url: parentEntityMetadata?.apisListPath?.(api.parentId)
url: parentEntityMetadata?.apisListPath?.(api.parentId as string)
}
]
: [];
}

private fetchEntity(id: string, parentEntityMetadata?: EntityMetadata): Observable<ApiBreadcrumbDetails> {
return this.timeRangeService.getTimeRangeAndChanges().pipe(
switchMap(timeRange =>
this.graphQlQueryService.query<EntityGraphQlQueryHandlerService, ApiBreadcrumbDetails>(
{
requestType: ENTITY_GQL_REQUEST,
entityType: ObservabilityEntityType.Api,
id: id,
properties: this.getAttributeKeys(parentEntityMetadata).map(attributeKey =>
this.specificationBuilder.attributeSpecificationForKey(attributeKey)
),
timeRange: new GraphQlTimeRange(timeRange.startTime, timeRange.endTime)
},
{ cacheability: GraphQlRequestCacheability.NotCacheable }
)
),
map(apiEntity => ({
...apiEntity,
...this.getParentPartial(apiEntity, parentEntityMetadata)
}))
);
}

private getAttributeKeys(parentTypeMetadata?: EntityMetadata): string[] {
protected getAttributeKeys(): string[] {
const parentTypeMetadata = this.resolveParentType();
const parentAttributes = parentTypeMetadata
? [this.getParentNameAttribute(parentTypeMetadata), this.getParentIdAttribute(parentTypeMetadata)]
: [];
Expand Down Expand Up @@ -142,7 +127,7 @@ export class ApiDetailBreadcrumbResolver implements Resolve<Observable<Breadcrum
}
}

export interface ApiBreadcrumbDetails extends Entity<ObservabilityEntityType.Api> {
export interface ApiBreadcrumbDetails extends EntityBreadcrumb {
name: string;
parentName: string;
parentId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class ApiDetailService extends EntityDetailService<ApiEntity> {

export interface ApiEntity extends Entity {
apiType: ApiType;
name: string;
}

export const enum ApiType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import { entityIdKey, entityTypeKey, ObservabilityEntityType } from '../../../sh
import { ENTITY_GQL_REQUEST } from '../../../shared/graphql/request/handlers/entities/query/entity/entity-graphql-query-handler.service';
import { ObservabilityIconType } from '../../../shared/icons/observability-icon-type';
import { EntityIconLookupService } from '../../../shared/services/entity/entity-icon-lookup.service';
import { EntityBreadcrumb } from './../../../shared/services/entity-breadcrumb/entity-breadcrumb.resolver';
import { ServiceDetailBreadcrumbResolver } from './service-detail-breadcrumb.resolver';

describe('Service detail breadcrumb resolver', () => {
let spectator: SpectatorService<ServiceDetailBreadcrumbResolver>;
let spectator: SpectatorService<ServiceDetailBreadcrumbResolver<EntityBreadcrumb>>;
let activatedRouteSnapshot: ActivatedRouteSnapshot;
const buildResolver = createServiceFactory({
service: ServiceDetailBreadcrumbResolver,
Expand Down Expand Up @@ -55,8 +56,11 @@ describe('Service detail breadcrumb resolver', () => {
runFakeRxjs(({ expectObservable }) => {
expectObservable(breadcrumb$).toBe('(x|)', {
x: {
[entityTypeKey]: ObservabilityEntityType.Service,
[entityIdKey]: 'test-id',
label: 'test service',
icon: ObservabilityIconType.Service
icon: ObservabilityIconType.Service,
name: 'test service'
}
});
});
Expand All @@ -69,7 +73,7 @@ describe('Service detail breadcrumb resolver', () => {
entityType: ObservabilityEntityType.Service,
id: 'test-id'
}),
{ cacheability: GraphQlRequestCacheability.NotCacheable }
{ cacheability: GraphQlRequestCacheability.Cacheable }
);
}));
});
Original file line number Diff line number Diff line change
@@ -1,60 +1,29 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import { Breadcrumb, TimeRangeService } from '@hypertrace/common';
import { GraphQlRequestCacheability, GraphQlRequestService } from '@hypertrace/graphql-client';
import { ActivatedRouteSnapshot } from '@angular/router';
import { TimeRangeService } from '@hypertrace/common';
import { GraphQlRequestService } from '@hypertrace/graphql-client';
import { Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { ObservabilityEntityType } from '../../../shared/graphql/model/schema/entity';
import { GraphQlTimeRange } from '../../../shared/graphql/model/schema/timerange/graphql-time-range';
import { SpecificationBuilder } from '../../../shared/graphql/request/builders/specification/specification-builder';
import {
EntityGraphQlQueryHandlerService,
ENTITY_GQL_REQUEST
} from '../../../shared/graphql/request/handlers/entities/query/entity/entity-graphql-query-handler.service';
import { EntityIconLookupService } from '../../../shared/services/entity/entity-icon-lookup.service';
import { ServiceEntity } from './service-detail.service';
EntityBreadcrumb,
EntityBreadcrumbResolver
} from '../../../shared/services/entity-breadcrumb/entity-breadcrumb.resolver';
import { EntityIconLookupService } from './../../../shared/services/entity/entity-icon-lookup.service';

@Injectable({ providedIn: 'root' })
export class ServiceDetailBreadcrumbResolver implements Resolve<Observable<Breadcrumb>> {
private readonly specificationBuilder: SpecificationBuilder = new SpecificationBuilder();

export class ServiceDetailBreadcrumbResolver<T extends EntityBreadcrumb> extends EntityBreadcrumbResolver<T> {
public constructor(
private readonly timeRangeService: TimeRangeService,
private readonly graphQlQueryService: GraphQlRequestService,
protected readonly iconLookupService: EntityIconLookupService
) {}
timeRangeService: TimeRangeService,
graphQlQueryService: GraphQlRequestService,
iconLookupService: EntityIconLookupService
) {
super(timeRangeService, graphQlQueryService, iconLookupService);
}

public async resolve(activatedRouteSnapshot: ActivatedRouteSnapshot): Promise<Observable<Breadcrumb>> {
public async resolve(activatedRouteSnapshot: ActivatedRouteSnapshot): Promise<Observable<T>> {
const id = activatedRouteSnapshot.paramMap.get('id');

return Promise.resolve(
this.fetchEntity(id as string).pipe(
take(1),
map(service => ({
label: service.name,
icon: this.iconLookupService.forEntity(service)
}))
)
);
}

protected fetchEntity(id: string): Observable<ServiceEntity> {
return this.timeRangeService.getTimeRangeAndChanges().pipe(
switchMap(timeRange =>
this.graphQlQueryService.query<EntityGraphQlQueryHandlerService, ServiceEntity>(
{
requestType: ENTITY_GQL_REQUEST,
entityType: ObservabilityEntityType.Service,
id: id,
properties: this.getAttributeKeys().map(attributeKey =>
this.specificationBuilder.attributeSpecificationForKey(attributeKey)
),
timeRange: new GraphQlTimeRange(timeRange.startTime, timeRange.endTime)
},
{ cacheability: GraphQlRequestCacheability.NotCacheable }
)
)
);
return Promise.resolve(this.fetchEntity(id as string, ObservabilityEntityType.Service));
}

protected getAttributeKeys(): string[] {
Expand Down
1 change: 1 addition & 0 deletions projects/observability/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export * from './shared/graphql/model/schema/trace';
// Services
export * from './pages/trace-detail/trace-detail.service';
export * from './shared/services/log-events/log-events.service';
export * from './shared/services/entity-breadcrumb/entity-breadcrumb.resolver';

// Span Detail
export { SpanData } from './shared/components/span-detail/span-data';
Expand Down
Loading