diff --git a/src/app/components/graph/graph.component.html b/src/app/components/graph/graph.component.html index bdcfe0a1b..4cdd36a89 100644 --- a/src/app/components/graph/graph.component.html +++ b/src/app/components/graph/graph.component.html @@ -1,4 +1,4 @@ - +

@@ -35,5 +35,9 @@

+@let node = selectedNode(); +@if (node) { + +}
\ No newline at end of file diff --git a/src/app/components/graph/graph.component.scss b/src/app/components/graph/graph.component.scss index 3ba1eb3fb..be1f5a72e 100644 --- a/src/app/components/graph/graph.component.scss +++ b/src/app/components/graph/graph.component.scss @@ -1,17 +1,17 @@ @use 'styles' as *; -mat-card { +#graph-session-header { position: absolute; z-index: 2; top: 1rem; left: 3rem; width: fit-content; -} -mat-card-content { - @include flex-row(); - min-width: 60vw; - padding: 0.5rem !important; + mat-card-content { + @include flex-row(); + min-width: 60vw; + padding: 0.5rem !important; + } } section { diff --git a/src/app/components/graph/graph.component.spec.ts b/src/app/components/graph/graph.component.spec.ts index 31db87869..8239ce9d9 100644 --- a/src/app/components/graph/graph.component.spec.ts +++ b/src/app/components/graph/graph.component.spec.ts @@ -1,16 +1,13 @@ -import { ResultStatus, SessionStatus, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; import { ElementRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { ResultsStatusesService } from '@app/results/services/results-statuses.service'; -import { SessionsStatusesService } from '@app/sessions/services/sessions-statuses.service'; -import { TasksStatusesService } from '@app/tasks/services/tasks-statuses.service'; import { ArmoniKGraphNode, GraphData, GraphLink, LinkType } from '@app/types/graph.types'; import { DefaultConfigService } from '@services/default-config.service'; import { IconsService } from '@services/icons.service'; import { StorageService } from '@services/storage.service'; import { Subject } from 'rxjs'; import { GraphComponent } from './graph.component'; +import { NodeStatusService } from './services/node-status.service'; describe('GraphComponent', () => { let component: GraphComponent>; @@ -22,27 +19,6 @@ describe('GraphComponent', () => { setItem: jest.fn(), }; - const mockSessionsStatuses = { - statusToLabel: jest.fn(() => ({ - label: 'Running', - color: 'green' - })), - }; - - const mockTasksStatuses = { - statusToLabel: jest.fn(() => ({ - label: 'Completed', - color: 'green' - })), - }; - - const mockResultsStatuses = { - statusToLabel: jest.fn(() => ({ - label: 'Completed', - color: 'green' - })), - }; - const mockClipboard = { copy: jest.fn(), }; @@ -55,6 +31,10 @@ describe('GraphComponent', () => { nativeElement: {} } as ElementRef; + const mockNodeStatusService = { + getNodeStatusData: jest.fn(() => ({ label: 'status', color: 'green' })), + }; + beforeEach(() => { component = TestBed.configureTestingModule({ providers: [ @@ -62,9 +42,7 @@ describe('GraphComponent', () => { DefaultConfigService, { provide: StorageService, useValue: mockStorageService }, { provide: IconsService, useValue: mockIconsService }, - { provide: SessionsStatusesService, useValue: mockSessionsStatuses }, - { provide: TasksStatusesService, useValue: mockTasksStatuses }, - { provide: ResultsStatusesService, useValue: mockResultsStatuses }, + { provide: NodeStatusService, useValue: mockNodeStatusService }, { provide: Clipboard, useValue: mockClipboard }, ] }).inject(GraphComponent>); @@ -249,51 +227,6 @@ describe('GraphComponent', () => { }); }); - describe('getNodeStatusData', () => { - it('should return the running session label', () => { - const node = { - type: 'session', - status: SessionStatus.SESSION_STATUS_RUNNING, - } as ArmoniKGraphNode; - expect(component['getNodeStatusData'](node)).toEqual( - mockSessionsStatuses.statusToLabel() - ); - }); - - it('should return the running task label', () => { - const node = { - type: 'task', - status: TaskStatus.TASK_STATUS_COMPLETED, - } as ArmoniKGraphNode; - expect(component['getNodeStatusData'](node)).toEqual( - mockTasksStatuses.statusToLabel() - ); - }); - - it('should return the running result label', () => { - const node = { - type: 'result', - status: ResultStatus.RESULT_STATUS_COMPLETED, - } as ArmoniKGraphNode; - expect(component['getNodeStatusData'](node)).toEqual( - mockResultsStatuses.statusToLabel() - ); - }); - - - it('should get the default color', () => { - const node = { - type: 'unknown', - status: ResultStatus.RESULT_STATUS_COMPLETED, - } as unknown as ArmoniKGraphNode; - - expect(component['getNodeStatusData'](node)).toEqual({ - label: 'Unknown', - color: 'grey' - }); - }); - }); - describe('getLinkColor', () => { it('should get all kind of link colors', () => { const types: LinkType[] = ['dependency', 'output', 'parent', 'payload']; @@ -403,4 +336,15 @@ describe('GraphComponent', () => { expect(spy).toHaveBeenCalled(); }); }); + + it('should close node panel', () => { + component.selectedNode.set({ id: 'id' } as ArmoniKGraphNode); + component.closeNodePanel(); + expect(component.selectedNode()).toBeNull(); + }); + + it('should unsubscribe on destroy', () => { + component.ngOnDestroy(); + expect(grpcObservable.observed).toBeFalsy(); + }); }); \ No newline at end of file diff --git a/src/app/components/graph/graph.component.ts b/src/app/components/graph/graph.component.ts index d65dfbddf..8e4d9fb0a 100644 --- a/src/app/components/graph/graph.component.ts +++ b/src/app/components/graph/graph.component.ts @@ -1,6 +1,5 @@ -import { ResultStatus, SessionStatus, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; import { Clipboard } from '@angular/cdk/clipboard'; -import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -9,19 +8,31 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; +import { ResultsFiltersService } from '@app/results/services/results-filters.service'; +import { ResultsGrpcService } from '@app/results/services/results-grpc.service'; import { ResultsStatusesService } from '@app/results/services/results-statuses.service'; import { SessionsStatusesService } from '@app/sessions/services/sessions-statuses.service'; +import { TasksFiltersService } from '@app/tasks/services/tasks-filters.service'; +import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; import { TasksStatusesService } from '@app/tasks/services/tasks-statuses.service'; import { ArmoniKGraphNode, GraphData, GraphLink, LinkType } from '@app/types/graph.types'; -import { StatusLabelColor } from '@app/types/status'; +import { StatusService } from '@app/types/status'; import { DefaultConfigService } from '@services/default-config.service'; +import { FiltersService } from '@services/filters.service'; +import { GrpcSortFieldService } from '@services/grpc-sort-field.service'; import { IconsService } from '@services/icons.service'; import { StorageService } from '@services/storage.service'; +import { TableStorageService } from '@services/table-storage.service'; +import { TableURLService } from '@services/table-url.service'; +import { TableService } from '@services/table.service'; +import { UtilsService } from '@services/utils.service'; import { forceLink, forceManyBody } from 'd3'; import ForceGraph from 'force-graph'; import { Observable, Subject, Subscription, switchMap } from 'rxjs'; import { AutoCompleteComponent } from '../auto-complete.component'; import { GraphLegendComponent } from './graph-legend.component'; +import { InspectNodeComponent } from './inspect-node.component'; +import { NodeStatusService } from './services/node-status.service'; @Component({ selector: 'app-graph', @@ -38,12 +49,28 @@ import { GraphLegendComponent } from './graph-legend.component'; RouterModule, GraphLegendComponent, MatTooltipModule, - AutoCompleteComponent + AutoCompleteComponent, + InspectNodeComponent, ], providers: [ SessionsStatusesService, TasksStatusesService, ResultsStatusesService, + TasksGrpcService, + TasksFiltersService, + ResultsGrpcService, + ResultsFiltersService, + FiltersService, + UtilsService, + TableService, + TableURLService, + TableStorageService, + { + provide: StatusService, + useClass: TasksStatusesService, + }, + GrpcSortFieldService, + NodeStatusService, ], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -65,12 +92,10 @@ export class GraphComponent> colorMap: Record; private readonly iconsService = inject(IconsService); - private readonly sessionsStatusesService = inject(SessionsStatusesService); - private readonly tasksStatusesService = inject(TasksStatusesService); - private readonly resultsStatusesService = inject(ResultsStatusesService); private readonly storageService = inject(StorageService); private readonly defaultConfigService = inject(DefaultConfigService); private readonly clipboard = inject(Clipboard); + private readonly nodeStatusService = inject(NodeStatusService); private readonly redrawGraph$ = new Subject(); @@ -82,6 +107,8 @@ export class GraphComponent> nodesIds: string[] = []; readonly highlightLabel = $localize`Highlight a task`; + readonly selectedNode = signal(null); + ngOnInit(): void { const storedColorMap = this.storageService.getItem>('graph-links-colors', true) as Record | null; this.colorMap = storedColorMap ?? this.defaultConfigService.defaultGraphLinksColors; @@ -112,8 +139,11 @@ export class GraphComponent> .linkWidth(4) .nodeLabel('id') .onNodeClick((node: N) => { - this.graph.centerAt(node.x, node.y); - this.graph.zoom(4, 2000); + if (node.type !== 'session') { + this.selectedNode.set(node); + } + this.graph.centerAt(node.x, node.y, 2000); + this.graph.zoom(1.2, 2000); }); this.subscription.add(this.grpcObservable.subscribe((result) => this.subscribeToData(result))); @@ -320,7 +350,7 @@ export class GraphComponent> */ private drawNode(node: N, ctx: CanvasRenderingContext2D) { if (node.x !== undefined && node.y !== undefined) { - const label = this.getNodeStatusData(node); + const label = this.nodeStatusService.getNodeStatusData(node); if (this.nodesToHighlight.has(node.id)) { ctx.font = '70px Material Icons'; const complementary = this.getComplementaryColor(label.color); @@ -333,27 +363,6 @@ export class GraphComponent> } } - /** - * Get the data associated to the node status. - * @param node N - * @returns StatusLabelColor, contains label, color and icon. - */ - private getNodeStatusData(node: N): StatusLabelColor { - switch (node.type) { - case 'session': - return this.sessionsStatusesService.statusToLabel(node.status as SessionStatus); - case 'task': - return this.tasksStatusesService.statusToLabel(node.status as TaskStatus); - case 'result': - return this.resultsStatusesService.statusToLabel(node.status as ResultStatus); - default: - return { - label: $localize`Unknown`, - color: 'grey', - }; - } - } - /** * Returns the associated link color. * @param link L @@ -362,4 +371,8 @@ export class GraphComponent> private getLinkColor(link: L): string { return this.colorMap[link.type]; } + + closeNodePanel() { + this.selectedNode.set(null); + } } \ No newline at end of file diff --git a/src/app/components/graph/inspect-node.component.css b/src/app/components/graph/inspect-node.component.css new file mode 100644 index 000000000..d057d62ad --- /dev/null +++ b/src/app/components/graph/inspect-node.component.css @@ -0,0 +1,30 @@ +mat-card { + z-index: 2; + position: absolute; + bottom: 0; + right: 0; + width: 30%; +} + +mat-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +article { + display: flex; + justify-content: space-between; + align-items: center; +} + +section { + display: flex; + align-items: center; + gap: 0.5rem; +} + +h3 { + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/src/app/components/graph/inspect-node.component.html b/src/app/components/graph/inspect-node.component.html new file mode 100644 index 000000000..1acce73f6 --- /dev/null +++ b/src/app/components/graph/inspect-node.component.html @@ -0,0 +1,24 @@ + + +
+
+

{{ node.id }}

+ +
+ +
+ +
+ @switch (node.type) { + @case ('task') { + + } + @case ('result') { + + } + } +
\ No newline at end of file diff --git a/src/app/components/graph/inspect-node.component.spec.ts b/src/app/components/graph/inspect-node.component.spec.ts new file mode 100644 index 000000000..a1ef7e248 --- /dev/null +++ b/src/app/components/graph/inspect-node.component.spec.ts @@ -0,0 +1,94 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { TestBed } from '@angular/core/testing'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { IconsService } from '@services/icons.service'; +import { InspectNodeComponent } from './inspect-node.component'; +import { NodeStatusService } from './services/node-status.service'; + +describe('InspectNodeComponent', () => { + let component: InspectNodeComponent; + + const mockIconsService = { + getIcon: jest.fn(), + }; + + const mockClipboard = { + copy: jest.fn(), + }; + + const mockNodeStatusService = { + getNodeStatusData: jest.fn(), + }; + + const node = { + id: 'id' + } as ArmoniKGraphNode; + + beforeEach(() => { + component = TestBed.configureTestingModule({ + providers: [ + InspectNodeComponent, + { provide: IconsService, useValue: mockIconsService }, + { provide: Clipboard, useValue: mockClipboard }, + { provide: NodeStatusService, useValue: mockNodeStatusService }, + ], + }).inject(InspectNodeComponent); + component.ngOnInit(); + component.node = node; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('init', () => { + it('should subscribe to copySubject', () => { + expect(component['copySubject'].observed).toBeTruthy(); + }); + + it('should set "copy" as the default copy icon', () => { + expect(component.copyIcon()).toEqual('copy'); + }); + }); + + it('should get icon', () => { + const icon = 'icon'; + component.getIcon(icon); + expect(mockIconsService.getIcon).toHaveBeenCalledWith(icon); + }); + + describe('copy', () => { + beforeEach(() => { + jest.useFakeTimers(); + component.copy(); + }); + it('should copy the node id', () => { + expect(mockClipboard.copy).toHaveBeenCalledWith(node.id); + }); + + it('should update the copy icon to "success"', () => { + expect(component.copyIcon()).toEqual('success'); + }); + + it('should update the copy icon back to "copy" after 2 seconds', () => { + jest.advanceTimersByTime(2000); + expect(component.copyIcon()).toEqual('copy'); + }); + }); + + it('should emit on close', () => { + const spy = jest.spyOn(component.closePanel, 'emit'); + component.onClose(); + expect(spy).toHaveBeenCalled(); + }); + + describe('On destroy', () => { + beforeEach(() => { + component.ngOnDestroy(); + }); + + it('should unsubscribe', () => { + expect(component['subscriptions'].closed).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/app/components/graph/inspect-node.component.ts b/src/app/components/graph/inspect-node.component.ts new file mode 100644 index 000000000..17c50d603 --- /dev/null +++ b/src/app/components/graph/inspect-node.component.ts @@ -0,0 +1,71 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { StatusChipComponent } from '@components/status-chip.component'; +import { IconsService } from '@services/icons.service'; +import { delay, Subject, Subscription } from 'rxjs'; +import { InspectResultActionsComponent } from './inspect-result-actions.component'; +import { InspectTaskActionsComponent } from './inspect-task-actions.component'; +import { NodeStatusService } from './services/node-status.service'; + +/** + * Component displayed when a node (task, result) is clicked on the graph. + * Display its Id, status and some actions. + */ +@Component({ + selector: 'app-graph-inspect-node', + templateUrl: 'inspect-node.component.html', + styleUrl: 'inspect-node.component.css', + imports: [ + MatCardModule, + MatButtonModule, + MatIconModule, + StatusChipComponent, + InspectTaskActionsComponent, + InspectResultActionsComponent, + MatTooltipModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InspectNodeComponent implements OnInit, OnDestroy { + readonly nodeStatusService = inject(NodeStatusService); + private readonly iconsService = inject(IconsService); + private readonly clipboard = inject(Clipboard); + + @Input({ required: true }) node: N; + + @Output() readonly closePanel = new EventEmitter(); + + copyIcon = signal('copy'); + private readonly copySubject = new Subject(); + private readonly subscriptions = new Subscription(); + + ngOnInit(): void { + const copySubscription = this.copySubject + .pipe(delay(2000)) + .subscribe(() => (this.copyIcon.set('copy'))); + this.subscriptions.add(copySubscription); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + getIcon(name: string) { + return this.iconsService.getIcon(name); + } + + copy() { + this.clipboard.copy(this.node.id); + this.copyIcon.set('success'); + this.copySubject.next(); + } + + onClose() { + this.closePanel.emit(); + } +} \ No newline at end of file diff --git a/src/app/components/graph/inspect-result-actions.component.html b/src/app/components/graph/inspect-result-actions.component.html new file mode 100644 index 000000000..b00f98391 --- /dev/null +++ b/src/app/components/graph/inspect-result-actions.component.html @@ -0,0 +1,13 @@ + + @let resultData = result(); + @if (resultData) { + + @if (resultData.ownerTaskId) { + + } +} + \ No newline at end of file diff --git a/src/app/components/graph/inspect-result-actions.component.spec.ts b/src/app/components/graph/inspect-result-actions.component.spec.ts new file mode 100644 index 000000000..08962aa6f --- /dev/null +++ b/src/app/components/graph/inspect-result-actions.component.spec.ts @@ -0,0 +1,76 @@ +import { GetResultResponse, ResultRaw } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { ResultsGrpcService } from '@app/results/services/results-grpc.service'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { Subject } from 'rxjs'; +import { InspectResultActionsComponent } from './inspect-result-actions.component'; + +describe('InspectResultActionsComponent', () => { + let component: InspectResultActionsComponent; + + const getSubject = new Subject(); + const mockResultsGrpcService = { + get$: jest.fn(() => getSubject), + }; + + const mockRouter = { + navigate: jest.fn(), + }; + + const node = { + id: 'resultId', + } as ArmoniKGraphNode; + + const mockResult = { + resultId: 'resultId', + sessionId: 'sessionId', + ownerTaskId: 'ownerTaskId', + } as ResultRaw; + + beforeEach(() => { + component = TestBed.configureTestingModule({ + providers: [ + InspectResultActionsComponent, + { provide: ResultsGrpcService, useValue: mockResultsGrpcService }, + { provide: Router, useValue: mockRouter }, + ] + }).inject(InspectResultActionsComponent); + component.node = node; + getSubject.next({result: mockResult} as GetResultResponse); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Initialisation', () => { + it('should subscribe to the grpc get event', () => { + expect(getSubject.observed).toBeTruthy(); + }); + + it('should retrieve result information', () => { + expect(component.result()).toBe(mockResult); + }); + }); + + it('should redirect to the result page', () => { + component.seeResult(mockResult); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/results', mockResult.resultId]); + }); + + it('should redirect to the owner task page', () => { + component.seeOwnerTask(mockResult); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/tasks', mockResult.ownerTaskId]); + }); + + describe('On destroy', () => { + beforeEach(() => { + component.ngOnDestroy(); + }); + + it('should unsubscribe', () => { + expect(component['subscriptions'].closed).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/app/components/graph/inspect-result-actions.component.ts b/src/app/components/graph/inspect-result-actions.component.ts new file mode 100644 index 000000000..a5c265f3a --- /dev/null +++ b/src/app/components/graph/inspect-result-actions.component.ts @@ -0,0 +1,63 @@ +import { ResultRaw } from '@aneoconsultingfr/armonik.api.angular'; +import { ChangeDetectionStrategy, Component, inject, Input, OnDestroy, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardActions } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { Router } from '@angular/router'; +import { ResultsGrpcService } from '@app/results/services/results-grpc.service'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { Subscription } from 'rxjs'; + +/** + * Displays all actions available for a selected result on ArmoniK Graph. + */ +@Component({ + selector: 'app-graph-inspect-result-actions', + templateUrl: 'inspect-result-actions.component.html', + styleUrl: 'inspect-node.component.css', + imports: [ + MatButtonModule, + MatIconModule, + MatCardActions + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InspectResultActionsComponent implements OnDestroy { + private readonly grpcService = inject(ResultsGrpcService); + private readonly router = inject(Router); + + private readonly subscriptions = new Subscription(); + + result = signal(null); + + @Input({ required: true }) set node(entry: N) { + const getSubscription = this.grpcService.get$(entry.id) + .subscribe(result => { + if (result.result) { + this.result.set(result.result); + } + }); + + this.subscriptions.add(getSubscription); + }; + + /** + * Navigates to the result inspection page + * @param result ResultRaw + */ + seeResult(result: ResultRaw) { + this.router.navigate(['/results', result.resultId]); + } + + /** + * Navigates to the owner task inspection page + * @param result ResultRaw + */ + seeOwnerTask(result: ResultRaw) { + this.router.navigate(['/tasks', result.ownerTaskId]); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} \ No newline at end of file diff --git a/src/app/components/graph/inspect-task-actions.component.html b/src/app/components/graph/inspect-task-actions.component.html new file mode 100644 index 000000000..c90403c8f --- /dev/null +++ b/src/app/components/graph/inspect-task-actions.component.html @@ -0,0 +1,19 @@ + + @let taskData = task(); + @if (taskData) { + + + + @if (taskData.parentTaskIds.length !== 0) { + + } + } + \ No newline at end of file diff --git a/src/app/components/graph/inspect-task-actions.component.spec.ts b/src/app/components/graph/inspect-task-actions.component.spec.ts new file mode 100644 index 000000000..fc4273a62 --- /dev/null +++ b/src/app/components/graph/inspect-task-actions.component.spec.ts @@ -0,0 +1,113 @@ +import { GetTaskResponse, TaskDetailed } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { FiltersService } from '@services/filters.service'; +import { Subject } from 'rxjs'; +import { InspectTaskActionsComponent } from './inspect-task-actions.component'; + +describe('InspectTaskActionsComponent', () => { + let component: InspectTaskActionsComponent; + + const getSubject = new Subject(); + const mockTasksGrpcService = { + get$: jest.fn(() => getSubject), + }; + + const mockRouter = { + navigate: jest.fn(), + }; + + const node = { + id: 'taskId', + } as ArmoniKGraphNode; + + const mockTask = { + id: 'taskId', + options: { + paritionId: 'partitionId', + }, + parentTaskIds: [ + 'task-1', 'task-2', 'task-3' + ], + expectedOutputIds: [ + 'result-1', 'result-2' + ] + } as unknown as TaskDetailed; + + beforeEach(() => { + component = TestBed.configureTestingModule({ + providers: [ + InspectTaskActionsComponent, + FiltersService, + { provide: TasksGrpcService, useValue: mockTasksGrpcService }, + { provide: Router, useValue: mockRouter }, + ] + }).inject(InspectTaskActionsComponent); + component.node = node; + getSubject.next({task: mockTask} as GetTaskResponse); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Initialisation', () => { + it('should subscribe to the grpc get event', () => { + expect(getSubject.observed).toBeTruthy(); + }); + + it('should retrieve task information', () => { + expect(component.task()).toBe(mockTask); + }); + }); + + it('should redirect to the task page', () => { + component.seeTask(mockTask); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/tasks', mockTask.id]); + }); + + it('should redirect to the task partition', () => { + component.seePartition(mockTask); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/partitions', mockTask.options?.partitionId]); + }); + + it('should redirect to the filtered task table', () => { + component.listParentTasks(mockTask); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['/tasks'], + { + queryParams: { + '0-root-16-0': 'task-1', + '1-root-16-0': 'task-2', + '2-root-16-0': 'task-3', + } + } + ); + }); + + it('should redirect to the filtered result table', () => { + component.listResults(mockTask); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['/results'], + { + queryParams: { + '0-root-7-0': 'result-1', + '1-root-7-0': 'result-2', + } + } + ); + }); + + + describe('On destroy', () => { + beforeEach(() => { + component.ngOnDestroy(); + }); + + it('should unsubscribe', () => { + expect(component['subscriptions'].closed).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/app/components/graph/inspect-task-actions.component.ts b/src/app/components/graph/inspect-task-actions.component.ts new file mode 100644 index 000000000..5646e394f --- /dev/null +++ b/src/app/components/graph/inspect-task-actions.component.ts @@ -0,0 +1,108 @@ +import { FilterStringOperator, ResultRawEnumField, TaskDetailed, TaskSummaryEnumField } from '@aneoconsultingfr/armonik.api.angular'; +import { ChangeDetectionStrategy, Component, inject, Input, OnDestroy, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardActions } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { Params, Router } from '@angular/router'; +import { TasksGrpcService } from '@app/tasks/services/tasks-grpc.service'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { FiltersService } from '@services/filters.service'; +import { Subscription } from 'rxjs'; + +/** + * Displays all actions available for a selected task on ArmoniK Graph. + */ +@Component({ + selector: 'app-graph-inspect-task-actions', + templateUrl: 'inspect-task-actions.component.html', + styleUrl: 'inspect-node.component.css', + imports: [ + MatButtonModule, + MatIconModule, + MatCardActions + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InspectTaskActionsComponent implements OnDestroy { + private readonly grpcService = inject(TasksGrpcService); + private readonly filtersService = inject(FiltersService); + private readonly router = inject(Router); + + private readonly subscriptions = new Subscription(); + + task = signal(null); + + @Input({ required: true }) set node(entry: N) { + const getSubscription = this.grpcService.get$(entry.id) + .subscribe(result => { + if (result.task) { + this.task.set(result.task); + } + }); + + this.subscriptions.add(getSubscription); + }; + + /** + * Navigates to the task inspection page + * @param task TaskDetailed + */ + seeTask(task: TaskDetailed) { + this.router.navigate(['/tasks', task.id]); + } + + /** + * Navigates to the partition inspection page + * @param task TaskDetailed + */ + seePartition(task: TaskDetailed) { + if (task.options) { + this.router.navigate(['/partitions', task.options.partitionId]); + } + } + + /** + * Navigates to the task table, filtered on all parent tasks ids. + * @param task TaskDetailed + */ + listParentTasks(task: TaskDetailed) { + const queryParams = task.parentTaskIds + .filter(parent => parent !== task.sessionId) + .map((parent, index) => { + const key = this.filtersService.createQueryParamsKey(index, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, TaskSummaryEnumField.TASK_SUMMARY_ENUM_FIELD_TASK_ID); + return [key, parent]; + }) + .reduce((record: Params, [key, parent]) => { + record[key] = parent; + return record; + }, {}); + + this.router.navigate(['/tasks'], { + queryParams: queryParams, + }); + } + + /** + * Navigates to the result table, filtered on all expected output ids. + * @param task + */ + listResults(task: TaskDetailed) { + const queryParams = task.expectedOutputIds + .map((parent, index) => { + const key = this.filtersService.createQueryParamsKey(index, 'root', FilterStringOperator.FILTER_STRING_OPERATOR_EQUAL, ResultRawEnumField.RESULT_RAW_ENUM_FIELD_RESULT_ID); + return [key, parent]; + }) + .reduce((record: Params, [key, parent]) => { + record[key] = parent; + return record; + }, {}); + + this.router.navigate(['/results'], { + queryParams: queryParams, + }); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} \ No newline at end of file diff --git a/src/app/components/graph/services/node-status.service.spec.ts b/src/app/components/graph/services/node-status.service.spec.ts new file mode 100644 index 000000000..4e9573ab9 --- /dev/null +++ b/src/app/components/graph/services/node-status.service.spec.ts @@ -0,0 +1,89 @@ +import { ResultStatus, SessionStatus, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; +import { TestBed } from '@angular/core/testing'; +import { ResultsStatusesService } from '@app/results/services/results-statuses.service'; +import { SessionsStatusesService } from '@app/sessions/services/sessions-statuses.service'; +import { TasksStatusesService } from '@app/tasks/services/tasks-statuses.service'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { NodeStatusService } from './node-status.service'; + +describe('NodeStatusService', () => { + let service: NodeStatusService; + + const mockSessionsStatuses = { + statusToLabel: jest.fn(() => ({ + label: 'Running', + color: 'green' + })), + }; + + const mockTasksStatuses = { + statusToLabel: jest.fn(() => ({ + label: 'Completed', + color: 'green' + })), + }; + + const mockResultsStatuses = { + statusToLabel: jest.fn(() => ({ + label: 'Completed', + color: 'green' + })), + }; + + + beforeEach(() => { + service = TestBed.configureTestingModule({ + providers: [ + NodeStatusService, + { provide: SessionsStatusesService, useValue: mockSessionsStatuses }, + { provide: TasksStatusesService, useValue: mockTasksStatuses }, + { provide: ResultsStatusesService, useValue: mockResultsStatuses }, + ], + }).inject(NodeStatusService); + }); + + describe('getNodeStatusData', () => { + it('should return the running session label', () => { + const node = { + type: 'session', + status: SessionStatus.SESSION_STATUS_RUNNING, + } as ArmoniKGraphNode; + expect(service.getNodeStatusData(node)).toEqual( + mockSessionsStatuses.statusToLabel() + ); + }); + + it('should return the running task label', () => { + const node = { + type: 'task', + status: TaskStatus.TASK_STATUS_COMPLETED, + } as ArmoniKGraphNode; + expect(service.getNodeStatusData(node)).toEqual( + mockTasksStatuses.statusToLabel() + ); + }); + + it('should return the running result label', () => { + const node = { + type: 'result', + status: ResultStatus.RESULT_STATUS_COMPLETED, + } as ArmoniKGraphNode; + expect(service.getNodeStatusData(node)).toEqual( + mockResultsStatuses.statusToLabel() + ); + }); + + + it('should get the default color', () => { + const node = { + type: 'unknown', + status: ResultStatus.RESULT_STATUS_COMPLETED, + } as unknown as ArmoniKGraphNode; + + expect(service.getNodeStatusData(node)).toEqual({ + label: 'Unknown', + color: 'grey' + }); + }); + }); +}); \ No newline at end of file diff --git a/src/app/components/graph/services/node-status.service.ts b/src/app/components/graph/services/node-status.service.ts new file mode 100644 index 000000000..254744e84 --- /dev/null +++ b/src/app/components/graph/services/node-status.service.ts @@ -0,0 +1,38 @@ +import { ResultStatus, SessionStatus, TaskStatus } from '@aneoconsultingfr/armonik.api.angular'; +import { inject, Injectable } from '@angular/core'; +import { ResultsStatusesService } from '@app/results/services/results-statuses.service'; +import { SessionsStatusesService } from '@app/sessions/services/sessions-statuses.service'; +import { TasksStatusesService } from '@app/tasks/services/tasks-statuses.service'; +import { ArmoniKGraphNode } from '@app/types/graph.types'; +import { StatusLabelColor } from '@app/types/status'; + +/** + * Service responsible to provide data on nodes statuses for the graph. + */ +@Injectable() +export class NodeStatusService { + private readonly sessionsStatusesService = inject(SessionsStatusesService); + private readonly tasksStatusesService = inject(TasksStatusesService); + private readonly resultsStatusesService = inject(ResultsStatusesService); + + /** + * Get the data associated to the node status. + * @param node N + * @returns StatusLabelColor, contains label, color and icon. + */ + getNodeStatusData(node: N): StatusLabelColor { + switch (node.type) { + case 'session': + return this.sessionsStatusesService.statusToLabel(node.status as SessionStatus); + case 'task': + return this.tasksStatusesService.statusToLabel(node.status as TaskStatus); + case 'result': + return this.resultsStatusesService.statusToLabel(node.status as ResultStatus); + default: + return { + label: $localize`Unknown`, + color: 'grey', + }; + } + } +} \ No newline at end of file