[NIFI-12553] - Context menu option for View Status History (#8193)

* [NIFI-12553] - Context menu option for View Status History
* Added View Status History to the canvas context menu for applicable component types
* Added support for Node Status History from the flow menu

* remove unused imports

This closes #8193
This commit is contained in:
Rob Fellows 2024-01-02 17:14:43 -05:00 committed by GitHub
parent a7c9eccf4a
commit 689f990978
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 227 additions and 41 deletions

View File

@ -16,8 +16,6 @@
*/
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { CanvasUtils } from './canvas-utils.service';
import { Store } from '@ngrx/store';
import { CanvasState } from '../state';
@ -33,18 +31,17 @@ import {
navigateToEditComponent,
navigateToEditCurrentProcessGroup,
navigateToProvenanceForComponent,
navigateToViewStatusHistoryForComponent,
reloadFlow,
replayLastProvenanceEvent
} from '../state/flow/flow.actions';
import { ComponentType } from '../../../state/shared';
import { DeleteComponentRequest, MoveComponentRequest } from '../state/flow';
import {
ContextMenu,
ContextMenuDefinition,
ContextMenuDefinitionProvider,
ContextMenuItemDefinition
} from '../../../ui/common/context-menu/context-menu.component';
import { selection } from 'd3';
@Injectable({ providedIn: 'root' })
export class CanvasContextMenu implements ContextMenuDefinitionProvider {
@ -550,14 +547,21 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
isSeparator: true
},
{
condition: function (canvasUtils: CanvasUtils, selection: any) {
// TODO - supportsStats
return false;
condition: (canvasUtils: CanvasUtils, selection: any) => {
return canvasUtils.canViewStatusHistory(selection);
},
clazz: 'fa fa-area-chart',
text: 'View status history',
action: function (store: Store<CanvasState>) {
// TODO - showStats
action: (store: Store<CanvasState>, selection: any) => {
const selectionData = selection.datum();
store.dispatch(
navigateToViewStatusHistoryForComponent({
request: {
type: selectionData.type,
id: selectionData.id
}
})
);
}
},
{

View File

@ -228,7 +228,7 @@ export class CanvasUtils {
// get the selection data
const selectionData: any = selection.datum();
let supportsModification: boolean = false;
let supportsModification = false;
if (this.isProcessor(selection) || this.isInputPort(selection) || this.isOutputPort(selection)) {
supportsModification = !(
selectionData.status.aggregateSnapshot.runStatus === 'Running' ||
@ -246,8 +246,8 @@ export class CanvasUtils {
} else if (this.isLabel(selection)) {
supportsModification = true;
} else if (this.isConnection(selection)) {
let isSourceConfigurable: boolean = false;
let isDestinationConfigurable: boolean = false;
let isSourceConfigurable = false;
let isDestinationConfigurable = false;
const sourceComponentId: string = this.getConnectionSourceComponentId(selectionData);
const source: any = d3.select('#id-' + sourceComponentId);
@ -286,7 +286,7 @@ export class CanvasUtils {
}
const self: CanvasUtils = this;
let isDeletable: boolean = true;
let isDeletable = true;
selection.each(function (this: any) {
if (!self.isDeletable(d3.select(this))) {
isDeletable = false;
@ -487,6 +487,24 @@ export class CanvasUtils {
return this.isProcessor(selection) && this.canAccessProvenance();
}
/**
* Determines whether the current user can view the status history for the selected component.
*
* @param {selection} selection
*/
public canViewStatusHistory(selection: any): boolean {
if (selection.size() !== 1) {
return false;
}
return (
this.isProcessor(selection) ||
this.isConnection(selection) ||
this.isRemoteProcessGroup(selection) ||
this.isProcessGroup(selection)
);
}
/**
* Gets the currently selected components and connections.
*
@ -639,7 +657,7 @@ export class CanvasUtils {
const connections: Map<string, any> = new Map<string, any>();
const components: Map<string, any> = new Map<string, any>();
let isDisconnected: boolean = true;
let isDisconnected = true;
// include connections
selection
@ -894,7 +912,7 @@ export class CanvasUtils {
* @param tooltipData
*/
public canvasTooltip<C>(viewContainerRef: ViewContainerRef, type: Type<C>, selection: any, tooltipData: any): void {
let closeTimer: number = -1;
let closeTimer = -1;
let tooltipRef: ComponentRef<C> | undefined;
selection
@ -1034,7 +1052,7 @@ export class CanvasUtils {
* @param {string} cacheName
*/
public multilineEllipsis(selection: any, lineCount: number, text: string, cacheName: string) {
let i: number = 1;
let i = 1;
const words: string[] = text.split(/\s+/).reverse();
// get the appropriate position
@ -1047,7 +1065,7 @@ export class CanvasUtils {
// go through each word
let word = words.pop();
while (!!word) {
while (word) {
// add the current word
line.push(word);
@ -1069,7 +1087,7 @@ export class CanvasUtils {
if (++i >= lineCount) {
// get the remainder using the current word and
// reversing whats left
var remainder = [word].concat(words.reverse());
const remainder = [word].concat(words.reverse());
// apply ellipsis to the last line
this.ellipsis(tspan, remainder.join(' '), cacheName);
@ -1103,7 +1121,7 @@ export class CanvasUtils {
// if there is active threads show the count, otherwise hide
if (activeThreads > 0 || terminatedThreads > 0) {
const generateThreadsTip = function () {
var tip = activeThreads + ' active threads';
let tip = activeThreads + ' active threads';
if (terminatedThreads > 0) {
tip += ' (' + terminatedThreads + ' terminated)';
}

View File

@ -56,6 +56,7 @@ import {
NavigateToControllerServicesRequest,
ReplayLastProvenanceEventRequest
} from './index';
import { StatusHistoryRequest } from '../../../../state/status-history';
/*
Loading Flow
@ -237,6 +238,15 @@ export const createComponentComplete = createAction(
props<{ response: CreateComponentResponse }>()
);
export const navigateToViewStatusHistoryForComponent = createAction(
'[Canvas] Navigate To Status History For Component',
props<{ request: OpenComponentDialogRequest }>()
);
export const viewStatusHistoryForComponent = createAction(
'[Canvas] View Status History for Component',
props<{ request: StatusHistoryRequest }>()
);
/*
Update Component Actions
*/

View File

@ -20,6 +20,7 @@ import { FlowService } from '../../service/flow.service';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as FlowActions from './flow.actions';
import * as ParameterActions from '../parameter/parameter.actions';
import * as StatusHistoryActions from '../../../../state/status-history/status-history.actions';
import {
asyncScheduler,
catchError,
@ -673,6 +674,49 @@ export class FlowEffects {
{ dispatch: false }
);
navigateToViewStatusHistoryForComponent$ = createEffect(
() =>
this.actions$.pipe(
ofType(FlowActions.navigateToViewStatusHistoryForComponent),
map((action) => action.request),
withLatestFrom(this.store.select(selectCurrentProcessGroupId)),
tap(([request, currentProcessGroupId]) => {
this.router.navigate([
'/process-groups',
currentProcessGroupId,
request.type,
request.id,
'history'
]);
})
),
{ dispatch: false }
);
completeStatusHistoryForComponent$ = createEffect(
() =>
this.actions$.pipe(
ofType(StatusHistoryActions.viewStatusHistoryComplete),
map((action) => action.request),
filter((request) => request.source === 'canvas'),
tap((request) => {
this.store.dispatch(
FlowActions.selectComponents({
request: {
components: [
{
id: request.componentId,
componentType: request.componentType
}
]
}
})
);
})
),
{ dispatch: false }
);
navigateToControllerServicesForProcessGroup$ = createEffect(
() =>
this.actions$.pipe(
@ -1943,8 +1987,8 @@ export class FlowEffects {
})
);
} else if (response.nodeSnapshots) {
let replayedCount: number = 0;
let unavailableCount: number = 0;
let replayedCount = 0;
let unavailableCount = 0;
response.nodeSnapshots.forEach((nodeResponse: any) => {
if (nodeResponse.snapshot.eventAvailable) {

View File

@ -121,6 +121,19 @@ export const selectEditedCurrentProcessGroup = createSelector(selectCurrentRoute
return null;
});
export const selectViewStatusHistoryComponent = createSelector(selectCurrentRoute, (route) => {
let selectedComponent: SelectedComponent | null = null;
if (route?.routeConfig?.path == 'history') {
if (route.params.id && route.params.type) {
selectedComponent = {
id: route.params.id,
componentType: route.params.type
};
}
}
return selectedComponent;
});
export const selectTransitionRequired = createSelector(selectFlowState, (state: FlowState) => state.transitionRequired);
export const selectDragging = createSelector(selectFlowState, (state: FlowState) => state.dragging);

View File

@ -29,7 +29,10 @@ const routes: Routes = [
{
path: ':type/:id',
component: Canvas,
children: [{ path: 'edit', component: Canvas }]
children: [
{ path: 'edit', component: Canvas },
{ path: 'history', component: Canvas }
]
}
]
}

View File

@ -53,7 +53,8 @@ import {
selectRemoteProcessGroup,
selectSingleEditedComponent,
selectSingleSelectedComponent,
selectSkipTransform
selectSkipTransform,
selectViewStatusHistoryComponent
} from '../../state/flow/flow.selectors';
import { filter, map, switchMap, take, withLatestFrom } from 'rxjs';
import { restoreViewport, zoomFit } from '../../state/transform/transform.actions';
@ -61,6 +62,7 @@ import { ComponentType } from '../../../../state/shared';
import { initialState } from '../../state/flow/flow.reducer';
import { ContextMenuDefinitionProvider } from '../../../../ui/common/context-menu/context-menu.component';
import { CanvasContextMenu } from '../../service/canvas-context-menu.service';
import { getStatusHistoryAndOpenDialog } from '../../../../state/status-history/status-history.actions';
@Component({
selector: 'fd-canvas',
@ -72,7 +74,7 @@ export class Canvas implements OnInit, OnDestroy {
private canvas: any;
private scale: number = INITIAL_SCALE;
private canvasClicked: boolean = false;
private canvasClicked = false;
constructor(
private viewContainerRef: ViewContainerRef,
@ -239,6 +241,28 @@ export class Canvas implements OnInit, OnDestroy {
})
);
});
this.store
.select(selectCurrentProcessGroupId)
.pipe(
filter((processGroupId) => processGroupId != initialState.id),
switchMap(() => this.store.select(selectViewStatusHistoryComponent)),
filter((selectedComponent) => selectedComponent != null),
takeUntilDestroyed()
)
.subscribe((component) => {
if (component) {
this.store.dispatch(
getStatusHistoryAndOpenDialog({
request: {
source: 'canvas',
componentType: component.componentType,
componentId: component.id
}
})
);
}
});
}
ngOnInit(): void {

View File

@ -110,7 +110,7 @@
<i class="fa fa-fw fa-history mr-2"></i>
Flow Configuration History
</button>
<button mat-menu-item class="global-menu-item">
<button mat-menu-item class="global-menu-item" (click)="viewNodeStatusHistory()">
<i class="fa fa-fw fa-area-chart mr-2"></i>
Node Status History
</button>

View File

@ -38,6 +38,7 @@ import { AsyncPipe, NgIf, NgOptimizedImage } from '@angular/common';
import { MatDividerModule } from '@angular/material/divider';
import { RouterLink } from '@angular/router';
import { FlowStatus } from './flow-status/flow-status.component';
import * as StatusHistoryActions from '../../../../../state/status-history/status-history.actions';
@Component({
selector: 'fd-header',
@ -84,4 +85,14 @@ export class HeaderComponent {
logout(): void {
this.authService.logout();
}
viewNodeStatusHistory(): void {
this.store.dispatch(
StatusHistoryActions.getNodeStatusHistoryAndOpenDialog({
request: {
source: 'menu'
}
})
);
}
}

View File

@ -29,7 +29,7 @@ export class StatusHistoryService {
private client: Client
) {}
getProcessorStatusHistory(componentType: ComponentType, componentId: string) {
getComponentStatusHistory(componentType: ComponentType, componentId: string) {
let componentPath: string;
switch (componentType) {
case ComponentType.Processor:
@ -51,4 +51,8 @@ export class StatusHistoryService {
`${StatusHistoryService.API}/flow/${componentPath}/${encodeURIComponent(componentId)}/status/history`
);
}
getNodeStatusHistory() {
return this.httpClient.get(`${StatusHistoryService.API}/controller/status/history`);
}
}

View File

@ -25,6 +25,10 @@ export interface StatusHistoryRequest {
componentType: ComponentType;
}
export interface NodeStatusHistoryRequest {
source: string;
}
export interface FieldDescriptor {
description: string;
field: string;

View File

@ -16,9 +16,9 @@
*/
import { createAction, props } from '@ngrx/store';
import { StatusHistoryRequest, StatusHistoryResponse } from './index';
import { NodeStatusHistoryRequest, StatusHistoryRequest, StatusHistoryResponse } from './index';
const STATUS_HISTORY_PREFIX: string = '[Status History]';
const STATUS_HISTORY_PREFIX = '[Status History]';
export const reloadStatusHistory = createAction(
`${STATUS_HISTORY_PREFIX} Reload Status History`,
@ -30,6 +30,11 @@ export const getStatusHistoryAndOpenDialog = createAction(
props<{ request: StatusHistoryRequest }>()
);
export const getNodeStatusHistoryAndOpenDialog = createAction(
`${STATUS_HISTORY_PREFIX} Get Node Status History and Open Dialog`,
props<{ request: NodeStatusHistoryRequest }>()
);
export const reloadStatusHistorySuccess = createAction(
`${STATUS_HISTORY_PREFIX} Reload Status History Success`,
props<{ response: StatusHistoryResponse }>()
@ -37,12 +42,12 @@ export const reloadStatusHistorySuccess = createAction(
export const loadStatusHistorySuccess = createAction(
`${STATUS_HISTORY_PREFIX} Load Status History Success`,
props<{ request: StatusHistoryRequest; response: StatusHistoryResponse }>()
props<{ request: StatusHistoryRequest | NodeStatusHistoryRequest; response: StatusHistoryResponse }>()
);
export const openStatusHistoryDialog = createAction(
`${STATUS_HISTORY_PREFIX} Open Status History Dialog`,
props<{ request: StatusHistoryRequest }>()
props<{ request: StatusHistoryRequest | NodeStatusHistoryRequest }>()
);
export const statusHistoryApiError = createAction(
@ -56,3 +61,8 @@ export const viewStatusHistoryComplete = createAction(
`${STATUS_HISTORY_PREFIX} View Status History Complete`,
props<{ request: StatusHistoryRequest }>()
);
export const viewNodeStatusHistoryComplete = createAction(
`${STATUS_HISTORY_PREFIX} View Node Status History Complete`,
props<{ request: NodeStatusHistoryRequest }>()
);

View File

@ -43,7 +43,7 @@ export class StatusHistoryEffects {
switchMap((request: StatusHistoryRequest) =>
from(
this.statusHistoryService
.getProcessorStatusHistory(request.componentType, request.componentId)
.getComponentStatusHistory(request.componentType, request.componentId)
.pipe(
map((response: any) =>
StatusHistoryActions.reloadStatusHistorySuccess({
@ -75,7 +75,7 @@ export class StatusHistoryEffects {
switchMap((request) =>
from(
this.statusHistoryService
.getProcessorStatusHistory(request.componentType, request.componentId)
.getComponentStatusHistory(request.componentType, request.componentId)
.pipe(
map((response: any) =>
StatusHistoryActions.loadStatusHistorySuccess({
@ -101,6 +101,37 @@ export class StatusHistoryEffects {
)
);
getNodeStatusHistoryAndOpenDialog$ = createEffect(() =>
this.actions$.pipe(
ofType(StatusHistoryActions.getNodeStatusHistoryAndOpenDialog),
map((action) => action.request),
switchMap((request) =>
from(
this.statusHistoryService.getNodeStatusHistory().pipe(
map((response: any) =>
StatusHistoryActions.loadStatusHistorySuccess({
request,
response: {
statusHistory: {
canRead: response.canRead,
statusHistory: response.statusHistory
}
}
})
),
catchError((error) =>
of(
StatusHistoryActions.statusHistoryApiError({
error: error.error
})
)
)
)
)
)
)
);
loadStatusHistorySuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(StatusHistoryActions.loadStatusHistorySuccess),
@ -122,15 +153,25 @@ export class StatusHistoryEffects {
dialogReference.afterClosed().subscribe((response) => {
if (response !== 'ROUTED') {
this.store.dispatch(
StatusHistoryActions.viewStatusHistoryComplete({
request: {
source: request.source,
componentType: request.componentType,
componentId: request.componentId
}
})
);
if ('componentType' in request) {
this.store.dispatch(
StatusHistoryActions.viewStatusHistoryComplete({
request: {
source: request.source,
componentType: request.componentType,
componentId: request.componentId
}
})
);
} else {
this.store.dispatch(
StatusHistoryActions.viewNodeStatusHistoryComplete({
request: {
source: request.source
}
})
);
}
}
});
})