NIFI-12445: Provenance Event Listing (#8133)

- Provenance Event Listing.
- View Provenance Event dialog.
- Provenance routing.
- Provenance search.
- Replay.
- Download content.
- View content.
- Addressing review feedback.
- Addressing review feedback.

This closes #8133
This commit is contained in:
Matt Gilman 2023-12-11 17:36:42 -05:00 committed by GitHub
parent 78b822c452
commit 4f59f46ce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 3839 additions and 41 deletions

View File

@ -16,5 +16,24 @@ export default {
headers: {
'X-ProxyPort': 4200
}
},
'/nifi-content-viewer/*': {
target: 'https://localhost:8443',
secure: false,
logLevel: 'debug',
changeOrigin: true,
headers: {
'X-ProxyPort': 4200
}
},
// the following entry is needed because the content viewer (and other UIs) load resources from existing nifi ui
'/nifi/*': {
target: 'https://localhost:8443',
secure: false,
logLevel: 'debug',
changeOrigin: true,
headers: {
'X-ProxyPort': 4200
}
}
};

View File

@ -29,6 +29,11 @@ const routes: Routes = [
canMatch: [authGuard],
loadChildren: () => import('./pages/settings/feature/settings.module').then((m) => m.SettingsModule)
},
{
path: 'provenance',
canMatch: [authGuard],
loadChildren: () => import('./pages/provenance/feature/provenance.module').then((m) => m.ProvenanceModule)
},
{
path: 'parameter-contexts',
canMatch: [authGuard],

View File

@ -20,7 +20,6 @@ import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FlowDesignerModule } from './pages/flow-designer/feature/flow-designer.module';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
@ -29,13 +28,14 @@ import { HTTP_INTERCEPTORS, HttpClientModule, HttpClientXsrfModule } from '@angu
import { NavigationActionTiming, RouterState, StoreRouterConnectingModule } from '@ngrx/router-store';
import { rootReducers } from './state';
import { UserEffects } from './state/user/user.effects';
import { LoginModule } from './pages/login/feature/login.module';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { LoadingInterceptor } from './service/interceptors/loading.interceptor';
import { AuthInterceptor } from './service/interceptors/auth.interceptor';
import { ExtensionTypesEffects } from './state/extension-types/extension-types.effects';
import { PollingInterceptor } from './service/interceptors/polling.interceptor';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { MatNativeDateModule } from '@angular/material/core';
import { AboutEffects } from './state/about/about.effects';
// @ts-ignore
@NgModule({
@ -54,13 +54,14 @@ import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
routerState: RouterState.Minimal,
navigationActionTiming: NavigationActionTiming.PostActivation
}),
EffectsModule.forRoot(UserEffects, ExtensionTypesEffects),
EffectsModule.forRoot(UserEffects, ExtensionTypesEffects, AboutEffects),
StoreDevtoolsModule.instrument({
maxAge: 25,
logOnly: environment.production,
autoPause: true
}),
MatProgressSpinnerModule
MatProgressSpinnerModule,
MatNativeDateModule
],
providers: [
{

View File

@ -27,6 +27,8 @@ import { CanvasState } from '../../state';
import { transformFeatureKey } from '../../state/transform';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
describe('ConnectableBehavior', () => {
let service: ConnectableBehavior;
@ -46,6 +48,10 @@ describe('ConnectableBehavior', () => {
{
selector: selectFlowState,
value: initialState[flowFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -28,6 +28,8 @@ import { transformFeatureKey } from '../../state/transform';
import { selectFlowState } from '../../state/flow/flow.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
describe('DraggableBehavior', () => {
let service: DraggableBehavior;
@ -51,6 +53,10 @@ describe('DraggableBehavior', () => {
{
selector: selectTransform,
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -27,6 +27,8 @@ import { transformFeatureKey } from '../../state/transform';
import * as fromTransform from '../../state/transform/transform.reducer';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
describe('QuickSelectBehavior', () => {
let service: QuickSelectBehavior;
@ -46,6 +48,10 @@ describe('QuickSelectBehavior', () => {
{
selector: selectFlowState,
value: initialState[flowFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -28,6 +28,8 @@ import { selectFlowState } from '../state/flow/flow.selectors';
import { selectTransform } from '../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../state/controller-services';
import * as fromControllerServices from '../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../state/user/user.selectors';
import * as fromUser from '../../../state/user/user.reducer';
describe('BirdseyeView', () => {
let service: BirdseyeView;
@ -51,6 +53,10 @@ describe('BirdseyeView', () => {
{
selector: selectTransform,
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -26,12 +26,15 @@ import {
selectCurrentProcessGroupId,
selectParentProcessGroupId
} from '../state/flow/flow.selectors';
import { initialState } from '../state/flow/flow.reducer';
import { initialState as initialFlowState } from '../state/flow/flow.reducer';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { BulletinsTip } from '../../../ui/common/tooltips/bulletins-tip/bulletins-tip.component';
import { Position } from '../state/shared';
import { ComponentType, Permissions } from '../../../state/shared';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { User } from '../../../state/user';
import { initialState as initialUserState } from '../../../state/user/user.reducer';
import { selectUser } from '../../../state/user/user.selectors';
@Injectable({
providedIn: 'root'
@ -42,9 +45,10 @@ export class CanvasUtils {
private destroyRef = inject(DestroyRef);
private trimLengthCaches: Map<string, Map<string, Map<number, number>>> = new Map();
private currentProcessGroupId: string = initialState.id;
private parentProcessGroupId: string | null = initialState.flow.processGroupFlow.parentGroupId;
private canvasPermissions: Permissions = initialState.flow.permissions;
private currentProcessGroupId: string = initialFlowState.id;
private parentProcessGroupId: string | null = initialFlowState.flow.processGroupFlow.parentGroupId;
private canvasPermissions: Permissions = initialFlowState.flow.permissions;
private currentUser: User = initialUserState.user;
private connections: any[] = [];
private readonly humanizeDuration: Humanizer;
@ -83,6 +87,13 @@ export class CanvasUtils {
.subscribe((connections) => {
this.connections = connections;
});
this.store
.select(selectUser)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.currentUser = user;
});
}
public hasDownstream(selection: any): boolean {
@ -124,6 +135,13 @@ export class CanvasUtils {
d3.select('path.connector').remove();
}
/**
* Determines whether the current user can access provenance.
*/
public canAccessProvenance(): boolean {
return this.currentUser.provenancePermissions.canRead;
}
/**
* Determines whether the specified selection is empty.
*
@ -435,6 +453,40 @@ export class CanvasUtils {
return selection.size() === 1 && selection.classed('funnel');
}
/**
* Determines whether the current user can access provenance for the specified component.
*
* @argument {selection} selection The selection
*/
public canAccessComponentProvenance(selection: any): boolean {
// ensure the correct number of components are selected
if (selection.size() !== 1) {
return false;
}
return (
!this.isLabel(selection) &&
!this.isConnection(selection) &&
!this.isProcessGroup(selection) &&
!this.isRemoteProcessGroup(selection) &&
this.canAccessProvenance()
);
}
/**
* Determines whether the current selection should provide ability to replay latest provenance event.
*
* @param {selection} selection
*/
public canReplayComponentProvenance(selection: any): boolean {
// ensure the correct number of components are selected
if (selection.size() !== 1) {
return false;
}
return this.isProcessor(selection) && this.canAccessProvenance();
}
/**
* Gets the currently selected components and connections.
*

View File

@ -28,6 +28,8 @@ import { selectFlowState } from '../state/flow/flow.selectors';
import { selectTransform } from '../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../state/controller-services';
import * as fromControllerServices from '../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../state/user/user.selectors';
import * as fromUser from '../../../state/user/user.reducer';
describe('CanvasView', () => {
let service: CanvasView;
@ -51,6 +53,10 @@ describe('CanvasView', () => {
{
selector: selectTransform,
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -26,6 +26,7 @@ import {
CreateProcessGroupRequest,
CreateProcessorRequest,
DeleteComponentRequest,
ReplayLastProvenanceEventRequest,
Snippet,
UpdateComponentRequest,
UploadProcessGroupRequest
@ -95,10 +96,6 @@ export class FlowService {
});
}
getCurrentUser(): Observable<any> {
return this.httpClient.get(`${FlowService.API}/flow/controller/bulletins`);
}
getProcessor(id: string): Observable<any> {
return this.httpClient.get(`${FlowService.API}/processors/${id}`);
}
@ -245,4 +242,8 @@ export class FlowService {
deleteSnippet(snippetId: string): Observable<any> {
return this.httpClient.delete(`${FlowService.API}/snippets/${snippetId}`);
}
replayLastProvenanceEvent(request: ReplayLastProvenanceEventRequest): Observable<any> {
return this.httpClient.post(`${FlowService.API}/provenance-events/latest/replays`, request);
}
}

View File

@ -28,6 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
describe('ConnectionManager', () => {
let service: ConnectionManager;
@ -51,6 +53,10 @@ describe('ConnectionManager', () => {
{
selector: selectTransform,
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -28,6 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
describe('FunnelManager', () => {
let service: FunnelManager;
@ -51,6 +53,10 @@ describe('FunnelManager', () => {
{
selector: selectTransform,
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -28,6 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
describe('RemoteProcessGroupManager', () => {
let service: RemoteProcessGroupManager;
@ -51,6 +53,10 @@ describe('RemoteProcessGroupManager', () => {
{
selector: selectTransform,
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -53,7 +53,8 @@ import {
UpdatePositionsRequest,
UploadProcessGroupRequest,
EditCurrentProcessGroupRequest,
NavigateToControllerServicesRequest
NavigateToControllerServicesRequest,
ReplayLastProvenanceEventRequest
} from './index';
/*
@ -375,3 +376,13 @@ export const renderConnectionsForComponent = createAction(
'[Canvas] Render Connections For Component',
props<{ id: string; updatePath: boolean; updateLabel: boolean }>()
);
export const navigateToProvenanceForComponent = createAction(
'[Canvas] Navigate To Provenance For Component',
props<{ id: string }>()
);
export const replayLastProvenanceEvent = createAction(
'[Canvas] Replay Last Provenance Event',
props<{ request: ReplayLastProvenanceEventRequest }>()
);

View File

@ -1770,6 +1770,94 @@ export class FlowEffects {
{ dispatch: false }
);
navigateToProvenanceForComponent$ = createEffect(
() =>
this.actions$.pipe(
ofType(FlowActions.navigateToProvenanceForComponent),
map((action) => action.id),
tap((componentId) => {
this.router.navigate(['/provenance'], { queryParams: { componentId } });
})
),
{ dispatch: false }
);
replayLastProvenanceEvent = createEffect(
() =>
this.actions$.pipe(
ofType(FlowActions.replayLastProvenanceEvent),
map((action) => action.request),
tap((request) => {
this.flowService.replayLastProvenanceEvent(request).subscribe({
next: (response) => {
if (response.aggregateSnapshot.failureExplanation) {
this.store.dispatch(
FlowActions.showOkDialog({
title: 'Replay Event Failure',
message: response.aggregateSnapshot.failureExplanation
})
);
} else if (response.aggregateSnapshot.eventAvailable !== true) {
this.store.dispatch(
FlowActions.showOkDialog({
title: 'No Event Available',
message: 'There was no recent event available to be replayed.'
})
);
} else if (response.nodeSnapshots) {
let replayedCount: number = 0;
let unavailableCount: number = 0;
response.nodeSnapshots.forEach((nodeResponse: any) => {
if (nodeResponse.snapshot.eventAvailable) {
replayedCount++;
} else {
unavailableCount++;
}
});
let message: string;
if (unavailableCount === 0) {
message = 'All nodes successfully replayed the latest event.';
} else {
message = `${replayedCount} nodes successfully replayed the latest event but ${unavailableCount} had no recent event available to be replayed.`;
}
this.store.dispatch(
FlowActions.showOkDialog({
title: 'Events Replayed',
message
})
);
} else {
this.store.dispatch(
FlowActions.showOkDialog({
title: 'Events Replayed',
message: 'Successfully replayed the latest event.'
})
);
}
this.store.dispatch(
FlowActions.loadConnectionsForComponent({
id: request.componentId
})
);
},
error: (error) => {
this.store.dispatch(
FlowActions.showOkDialog({
title: 'Failed to Replay Event',
message: error.error
})
);
}
});
})
),
{ dispatch: false }
);
showOkDialog$ = createEffect(
() =>
this.actions$.pipe(

View File

@ -304,6 +304,11 @@ export interface NavigateToComponentRequest {
processGroupId?: string;
}
export interface ReplayLastProvenanceEventRequest {
componentId: string;
nodes: string;
}
/*
Snippets
*/

View File

@ -30,7 +30,9 @@ import {
navigateToControllerServicesForProcessGroup,
navigateToEditComponent,
navigateToEditCurrentProcessGroup,
reloadFlow
navigateToProvenanceForComponent,
reloadFlow,
replayLastProvenanceEvent
} from '../../../state/flow/flow.actions';
import { CanvasUtils } from '../../../service/canvas-utils.service';
import { DeleteComponentRequest, MoveComponentRequest } from '../../../state/flow';
@ -154,24 +156,38 @@ export class ContextMenu implements OnInit {
menuItems: [
{
condition: function (canvasUtils: CanvasUtils, selection: any) {
// TODO - canReplayProvenance
return false;
return canvasUtils.canReplayComponentProvenance(selection);
},
clazz: 'fa',
text: 'All nodes',
action: function (store: Store<CanvasState>) {
// TODO - replayLastAllNodes
action: function (store: Store<CanvasState>, selection: any) {
const selectionData = selection.datum();
store.dispatch(
replayLastProvenanceEvent({
request: {
componentId: selectionData.id,
nodes: 'ALL'
}
})
);
}
},
{
condition: function (canvasUtils: CanvasUtils, selection: any) {
// TODO - canReplayProvenance
return false;
return canvasUtils.canReplayComponentProvenance(selection);
},
clazz: 'fa',
text: 'Primary node',
action: function (store: Store<CanvasState>) {
// TODO - replayLastPrimaryNode
action: function (store: Store<CanvasState>, selection: any) {
const selectionData = selection.datum();
store.dispatch(
replayLastProvenanceEvent({
request: {
componentId: selectionData.id,
nodes: 'PRIMARY'
}
})
);
}
}
]
@ -525,14 +541,18 @@ export class ContextMenu implements OnInit {
},
{
condition: function (canvasUtils: CanvasUtils, selection: any) {
// TODO - canAccessProvenance
return false;
return canvasUtils.canAccessComponentProvenance(selection);
},
clazz: 'icon icon-provenance',
// imgStyle: 'context-menu-provenance',
text: 'View data provenance',
action: function (store: Store<CanvasState>) {
// TODO - openProvenance
action: function (store: Store<CanvasState>, selection: any) {
const selectionData = selection.datum();
store.dispatch(
navigateToProvenanceForComponent({
id: selectionData.id
})
);
}
},
{
@ -958,6 +978,7 @@ export class ContextMenu implements OnInit {
) {
this.allMenus = new Map<string, ContextMenuDefinition>();
this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);
this.allMenus.set(this.PROVENANCE_REPLAY.id, this.PROVENANCE_REPLAY);
this.allMenus.set(this.VERSION_MENU.id, this.VERSION_MENU);
this.allMenus.set(this.UPSTREAM_DOWNSTREAM.id, this.UPSTREAM_DOWNSTREAM);
this.allMenus.set(this.ALIGN.id, this.ALIGN);

View File

@ -81,7 +81,11 @@
Bulletin Board
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="global-menu-item">
<button
mat-menu-item
class="global-menu-item"
[routerLink]="['/provenance']"
[disabled]="!user.provenancePermissions.canRead">
<i class="icon fa-fw icon-provenance mr-2"></i>
Data Provenance
</button>

View File

@ -36,6 +36,8 @@ import { ClusterSummary, ControllerStatus } from '../../../state/flow';
import { Search } from './search/search.component';
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { selectUser } from '../../../../../state/user/user.selectors';
import * as fromUser from '../../../../../state/user/user.reducer';
describe('HeaderComponent', () => {
let component: HeaderComponent;
@ -99,6 +101,10 @@ describe('HeaderComponent', () => {
{
selector: selectControllerBulletins,
value: []
},
{
selector: selectUser,
value: fromUser.initialState.user
}
]
})

View File

@ -114,6 +114,7 @@ export class CreateProcessGroup {
this.createProcessGroupForm
.get('newProcessGroupName')
?.setValue(this.nifiCommon.substringBeforeLast(file.name, '.'));
this.createProcessGroupForm.get('newProcessGroupName')?.markAsDirty();
this.createProcessGroupForm.get('newProcessGroupParameterContext')?.setValue(null);
this.flowNameAttached = file.name;
this.flowDefinition = file;

View File

@ -92,8 +92,4 @@ export class ParameterContextService {
params: revision
});
}
// updateControllerConfig(controllerEntity: ControllerEntity): Observable<any> {
// return this.httpClient.put(`${ControllerServiceService.API}/controller/config`, controllerEntity);
// }
}

View File

@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Provenance } from './provenance.component';
const routes: Routes = [
{
path: '',
component: Provenance,
children: [
// {
// path: 'lineage'
// },
{
path: '',
loadChildren: () =>
import('../ui/provenance-event-listing/provenance-event-listing.module').then(
(m) => m.ProvenanceEventListingModule
)
}
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ProvenanceRoutingModule {}

View File

@ -0,0 +1,28 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="p-4 flex flex-col h-screen justify-between gap-y-5">
<div class="flex justify-between">
<h3 class="text-xl bold provenance-header">Provenance</h3>
<button class="nifi-button" [routerLink]="['/']">
<i class="fa fa-times"></i>
</button>
</div>
<div class="flex-1">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -0,0 +1,20 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.provenance-header {
color: #728e9b;
}

View File

@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Provenance } from './provenance.component';
import { provideMockStore } from '@ngrx/store/testing';
import { RouterModule } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { initialState } from '../state/provenance-event-listing/provenance-event-listing.reducer';
describe('Provenance', () => {
let component: Provenance;
let fixture: ComponentFixture<Provenance>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [Provenance],
imports: [RouterModule, RouterTestingModule],
providers: [
provideMockStore({
initialState
})
]
});
fixture = TestBed.createComponent(Provenance);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { loadProvenanceOptions } from '../state/provenance-event-listing/provenance-event-listing.actions';
import { loadAbout } from '../../../state/about/about.actions';
@Component({
selector: 'provenance',
templateUrl: './provenance.component.html',
styleUrls: ['./provenance.component.scss']
})
export class Provenance implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(loadProvenanceOptions());
this.store.dispatch(loadAbout());
}
ngOnDestroy(): void {
this.store.dispatch(stopUserPolling());
}
}

View File

@ -0,0 +1,39 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { Provenance } from './provenance.component';
import { ProvenanceRoutingModule } from './provenance-routing.module';
import { provenanceFeatureKey, reducers } from '../state';
import { ProvenanceEventListingEffects } from '../state/provenance-event-listing/provenance-event-listing.effects';
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({
declarations: [Provenance],
exports: [Provenance],
imports: [
CommonModule,
MatDialogModule,
ProvenanceRoutingModule,
StoreModule.forFeature(provenanceFeatureKey, reducers),
EffectsModule.forFeature(ProvenanceEventListingEffects)
]
})
export class ProvenanceModule {}

View File

@ -0,0 +1,130 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { ProvenanceRequest } from '../state/provenance-event-listing';
@Injectable({ providedIn: 'root' })
export class ProvenanceService {
private static readonly API: string = '../nifi-api';
constructor(
private httpClient: HttpClient,
private nifiCommon: NiFiCommon
) {}
/**
* The NiFi model contain the url for each component. That URL is an absolute URL. Angular CSRF handling
* does not work on absolute URLs, so we need to strip off the proto for the request header to be added.
*
* https://stackoverflow.com/a/59586462
*
* @param url
* @private
*/
private stripProtocol(url: string): string {
return this.nifiCommon.substringAfterFirst(url, ':');
}
getSearchOptions(): Observable<any> {
return this.httpClient.get(`${ProvenanceService.API}/provenance/search-options`);
}
submitProvenanceQuery(request: ProvenanceRequest): Observable<any> {
return this.httpClient.post(`${ProvenanceService.API}/provenance`, { provenance: { request } });
}
getProvenanceQuery(id: string, clusterNodeId?: string): Observable<any> {
// TODO - cluster node id
return this.httpClient.get(`${ProvenanceService.API}/provenance/${encodeURIComponent(id)}`);
}
deleteProvenanceQuery(id: string, clusterNodeId?: string): Observable<any> {
// TODO - cluster node id
return this.httpClient.delete(`${ProvenanceService.API}/provenance/${encodeURIComponent(id)}`);
}
getProvenanceEvent(id: string): Observable<any> {
// TODO - cluster node id
return this.httpClient.get(`${ProvenanceService.API}/provenance-events/${encodeURIComponent(id)}`);
}
downloadContent(id: string, direction: string): void {
let dataUri: string = `${ProvenanceService.API}/provenance-events/${encodeURIComponent(
id
)}/content/${encodeURIComponent(direction)}`;
const queryParameters: any = {};
// TODO - cluster node id in query parameters
if (Object.keys(queryParameters).length > 0) {
const query: string = new URLSearchParams(queryParameters).toString();
dataUri = `${dataUri}?${query}`;
}
window.open(dataUri);
}
viewContent(nifiUrl: string, contentViewerUrl: string, id: string, direction: string): void {
// build the uri to the data
let dataUri: string = `${nifiUrl}provenance-events/${encodeURIComponent(id)}/content/${encodeURIComponent(
direction
)}`;
const dataUriParameters: any = {};
// TODO - cluster node id in data uri parameters
// include parameters if necessary
if (Object.keys(dataUriParameters).length > 0) {
const dataUriQuery: string = new URLSearchParams(dataUriParameters).toString();
dataUri = `${dataUri}?${dataUriQuery}`;
}
// if there's already a query string don't add another ?... this assumes valid
// input meaning that if the url has already included a ? it also contains at
// least one query parameter
let contentViewer: string = contentViewerUrl;
if (contentViewer.indexOf('?') === -1) {
contentViewer += '?';
} else {
contentViewer += '&';
}
const contentViewerParameters: any = {
ref: dataUri
};
// open the content viewer
const contentViewerQuery: string = new URLSearchParams(contentViewerParameters).toString();
window.open(`${contentViewer}${contentViewerQuery}`);
}
replay(eventId: string): Observable<any> {
const payload: any = {
eventId
};
// TODO - add cluster node id in payload
return this.httpClient.post(`${ProvenanceService.API}/provenance-events/replays`, payload);
}
}

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
Parameter Contexts
*/
import { Action, combineReducers, createFeatureSelector } from '@ngrx/store';
import { provenanceEventListingFeatureKey, ProvenanceEventListingState } from './provenance-event-listing';
import { provenanceEventListingReducer } from './provenance-event-listing/provenance-event-listing.reducer';
export const provenanceFeatureKey = 'provenance';
export interface ProvenanceState {
[provenanceEventListingFeatureKey]: ProvenanceEventListingState;
}
export function reducers(state: ProvenanceState | undefined, action: Action) {
return combineReducers({
[provenanceEventListingFeatureKey]: provenanceEventListingReducer
})(state, action);
}
export const selectProvenanceState = createFeatureSelector<ProvenanceState>(provenanceFeatureKey);

View File

@ -0,0 +1,100 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ProvenanceEventSummary } from '../../../../state/shared';
export const provenanceEventListingFeatureKey = 'provenanceEventListing';
export interface ProvenanceOptionsResponse {
provenanceOptions: ProvenanceOptions;
}
export interface ProvenanceQueryParams {
flowFileUuid?: string;
componentId?: string;
}
export interface ProvenanceQueryResponse {
provenance: Provenance;
}
export interface SearchableField {
field: string;
id: string;
label: string;
type: string;
}
export interface ProvenanceOptions {
searchableFields: SearchableField[];
}
export interface ProvenanceSearchDialogRequest {
timeOffset: number;
options: ProvenanceOptions;
currentRequest: ProvenanceRequest;
}
export interface ProvenanceSearchValue {
value: string;
inverse: boolean;
}
export interface ProvenanceRequest {
searchTerms?: {
[key: string]: ProvenanceSearchValue;
};
clusterNodeId?: string;
startDate?: string;
endDate?: string;
minimumFileSize?: string;
maximumFileSize?: string;
maxResults: number;
summarize: boolean;
incrementalResults: boolean;
}
export interface ProvenanceResults {
provenanceEvents: ProvenanceEventSummary[];
total: string;
totalCount: number;
generated: string;
oldestEvent: string;
timeOffset: number;
errors: string[];
}
export interface Provenance {
id: string;
uri: string;
submissionTime: string;
expiration: string;
percentCompleted: number;
finished: boolean;
request: ProvenanceRequest;
results: ProvenanceResults;
}
export interface ProvenanceEventListingState {
options: ProvenanceOptions | null;
request: ProvenanceRequest | null;
provenance: Provenance | null;
saving: boolean;
loadedTimestamp: string;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}

View File

@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createAction, props } from '@ngrx/store';
import { ProvenanceOptionsResponse, ProvenanceQueryResponse, ProvenanceRequest } from './index';
export const loadProvenanceOptions = createAction('[Provenance Event Listing] Load Provenance Options');
export const loadProvenanceOptionsSuccess = createAction(
'[Provenance Event Listing] Load Provenance Options Success',
props<{ response: ProvenanceOptionsResponse }>()
);
export const submitProvenanceQuery = createAction(
'[Provenance Event Listing] Submit Provenance Query',
props<{ request: ProvenanceRequest }>()
);
export const resubmitProvenanceQuery = createAction(
'[Provenance Event Listing] Resubmit Provenance Query',
props<{ request: ProvenanceRequest }>()
);
export const submitProvenanceQuerySuccess = createAction(
'[Provenance Event Listing] Submit Provenance Query Success',
props<{ response: ProvenanceQueryResponse }>()
);
export const startPollingProvenanceQuery = createAction('[Provenance Event Listing] Start Polling Provenance Query');
export const pollProvenanceQuery = createAction('[Provenance Event Listing] Poll Provenance Query');
export const pollProvenanceQuerySuccess = createAction(
'[Provenance Event Listing] Poll Provenance Query Success',
props<{ response: ProvenanceQueryResponse }>()
);
export const stopPollingProvenanceQuery = createAction('[Provenance Event Listing] Stop Polling Provenance Query');
export const deleteProvenanceQuery = createAction('[Provenance Event Listing] Delete Provenance Query');
export const provenanceApiError = createAction(
'[Provenance Event Listing] Load Parameter Context Listing Error',
props<{ error: string }>()
);
export const openProvenanceEventDialog = createAction(
'[Provenance Event Listing] Open Provenance Event Dialog',
props<{ id: string }>()
);
export const openSearchDialog = createAction('[Provenance Event Listing] Open Search Dialog');
export const saveProvenanceRequest = createAction(
'[Provenance Event Listing] Save Provenance Request',
props<{ request: ProvenanceRequest }>()
);
export const clearProvenanceRequest = createAction('[Provenance Event Listing] Clear Provenance Request');
export const showOkDialog = createAction(
'[Provenance Event Listing] Show Ok Dialog',
props<{ title: string; message: string }>()
);

View File

@ -0,0 +1,361 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as ProvenanceEventListingActions from './provenance-event-listing.actions';
import {
asyncScheduler,
catchError,
from,
interval,
map,
NEVER,
of,
switchMap,
take,
takeUntil,
tap,
withLatestFrom
} from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { Router } from '@angular/router';
import { OkDialog } from '../../../../ui/common/ok-dialog/ok-dialog.component';
import { ProvenanceService } from '../../service/provenance.service';
import {
selectClusterNodeId,
selectProvenanceId,
selectProvenanceOptions,
selectProvenanceRequest,
selectTimeOffset
} from './provenance-event-listing.selectors';
import { Provenance, ProvenanceRequest } from './index';
import { ProvenanceSearchDialog } from '../../ui/provenance-event-listing/provenance-search-dialog/provenance-search-dialog.component';
import { selectAbout } from '../../../../state/about/about.selectors';
import { ProvenanceEventDialog } from '../../../../ui/common/provenance-event-dialog/provenance-event-dialog.component';
import { CancelDialog } from '../../../../ui/common/cancel-dialog/cancel-dialog.component';
@Injectable()
export class ProvenanceEventListingEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private provenanceService: ProvenanceService,
private dialog: MatDialog,
private router: Router
) {}
loadProvenanceOptions$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.loadProvenanceOptions),
switchMap((request) =>
from(this.provenanceService.getSearchOptions()).pipe(
map((response) =>
ProvenanceEventListingActions.loadProvenanceOptionsSuccess({
response
})
),
catchError((error) =>
of(
ProvenanceEventListingActions.provenanceApiError({
error: error.error
})
)
)
)
)
)
);
submitProvenanceQuery$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.submitProvenanceQuery),
map((action) => action.request),
switchMap((request) =>
from(this.provenanceService.submitProvenanceQuery(request)).pipe(
map((response) =>
ProvenanceEventListingActions.submitProvenanceQuerySuccess({
response: {
provenance: response.provenance
}
})
),
catchError((error) => {
this.store.dispatch(
ProvenanceEventListingActions.showOkDialog({
title: 'Error',
message: error.error
})
);
return of(
ProvenanceEventListingActions.provenanceApiError({
error: error.error
})
);
})
)
)
)
);
resubmitProvenanceQuery = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.resubmitProvenanceQuery),
map((action) => action.request),
switchMap((request) => {
const dialogReference = this.dialog.open(CancelDialog, {
data: {
title: 'Provenance',
message: 'Searching provenance events...'
},
disableClose: true,
panelClass: 'small-dialog'
});
dialogReference.componentInstance.cancel.pipe(take(1)).subscribe(() => {
this.store.dispatch(ProvenanceEventListingActions.stopPollingProvenanceQuery());
});
return of(ProvenanceEventListingActions.submitProvenanceQuery({ request }));
})
)
);
submitProvenanceQuerySuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.submitProvenanceQuerySuccess),
map((action) => action.response),
switchMap((response) => {
const query: Provenance = response.provenance;
if (query.finished) {
this.dialog.closeAll();
return of(ProvenanceEventListingActions.deleteProvenanceQuery());
} else {
return of(ProvenanceEventListingActions.startPollingProvenanceQuery());
}
})
)
);
startPollingProvenanceQuery$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.startPollingProvenanceQuery),
switchMap(() =>
interval(2000, asyncScheduler).pipe(
takeUntil(this.actions$.pipe(ofType(ProvenanceEventListingActions.stopPollingProvenanceQuery)))
)
),
switchMap(() => of(ProvenanceEventListingActions.pollProvenanceQuery()))
)
);
pollProvenanceQuery$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.pollProvenanceQuery),
withLatestFrom(this.store.select(selectProvenanceId), this.store.select(selectClusterNodeId)),
switchMap(([action, id, clusterNodeId]) => {
if (id) {
return from(this.provenanceService.getProvenanceQuery(id, clusterNodeId)).pipe(
map((response) =>
ProvenanceEventListingActions.pollProvenanceQuerySuccess({
response: {
provenance: response.provenance
}
})
),
catchError((error) =>
of(
ProvenanceEventListingActions.provenanceApiError({
error: error.error
})
)
)
);
} else {
return NEVER;
}
})
)
);
pollProvenanceQuerySuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.pollProvenanceQuerySuccess),
map((action) => action.response),
switchMap((response) => {
const query: Provenance = response.provenance;
if (query.finished) {
this.dialog.closeAll();
return of(ProvenanceEventListingActions.stopPollingProvenanceQuery());
} else {
return NEVER;
}
})
)
);
stopPollingProvenanceQuery$ = createEffect(() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.stopPollingProvenanceQuery),
switchMap((response) => of(ProvenanceEventListingActions.deleteProvenanceQuery()))
)
);
deleteProvenanceQuery$ = createEffect(
() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.deleteProvenanceQuery),
withLatestFrom(this.store.select(selectProvenanceId), this.store.select(selectClusterNodeId)),
tap(([action, id, clusterNodeId]) => {
if (id) {
this.provenanceService.deleteProvenanceQuery(id, clusterNodeId).subscribe();
}
})
),
{ dispatch: false }
);
openSearchDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.openSearchDialog),
withLatestFrom(
this.store.select(selectTimeOffset),
this.store.select(selectProvenanceOptions),
this.store.select(selectProvenanceRequest),
this.store.select(selectAbout)
),
tap(([request, timeOffset, options, currentRequest, about]) => {
if (about) {
const dialogReference = this.dialog.open(ProvenanceSearchDialog, {
data: {
timeOffset,
options,
currentRequest
},
panelClass: 'large-dialog'
});
dialogReference.componentInstance.timezone = about.timezone;
dialogReference.componentInstance.submitSearchCriteria
.pipe(take(1))
.subscribe((request: ProvenanceRequest) => {
if (request.searchTerms) {
const queryParams: any = {};
if (request.searchTerms['ProcessorID']) {
queryParams['componentId'] = request.searchTerms['ProcessorID'].value;
}
if (request.searchTerms['FlowFileUUID']) {
queryParams['flowFileUuid'] = request.searchTerms['FlowFileUUID'].value;
}
// if either of the supported query params are present in the query, update the url
if (Object.keys(queryParams).length > 0) {
this.router.navigate(['/provenance'], { queryParams });
}
}
this.store.dispatch(ProvenanceEventListingActions.saveProvenanceRequest({ request }));
});
}
// TODO - if about hasn't loaded we should show an error
})
),
{ dispatch: false }
);
openProvenanceEventDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.openProvenanceEventDialog),
withLatestFrom(this.store.select(selectAbout)),
tap(([request, about]) => {
this.provenanceService.getProvenanceEvent(request.id).subscribe({
next: (response) => {
const dialogReference = this.dialog.open(ProvenanceEventDialog, {
data: {
event: response.provenanceEvent
},
panelClass: 'large-dialog'
});
dialogReference.componentInstance.contentViewerAvailable =
about?.contentViewerUrl != null ?? false;
dialogReference.componentInstance.downloadContent
.pipe(takeUntil(dialogReference.afterClosed()))
.subscribe((direction: string) => {
this.provenanceService.downloadContent(request.id, direction);
});
if (about) {
dialogReference.componentInstance.viewContent
.pipe(takeUntil(dialogReference.afterClosed()))
.subscribe((direction: string) => {
this.provenanceService.viewContent(
about.uri,
about.contentViewerUrl,
request.id,
direction
);
});
}
dialogReference.componentInstance.replay
.pipe(takeUntil(dialogReference.afterClosed()))
.subscribe(() => {
this.provenanceService.replay(request.id).subscribe(() => {
this.store.dispatch(
ProvenanceEventListingActions.showOkDialog({
title: 'Provenance',
message: 'Successfully submitted replay request.'
})
);
});
});
},
error: (error) => {
// TODO - handle error
}
});
})
),
{ dispatch: false }
);
showOkDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(ProvenanceEventListingActions.showOkDialog),
tap((request) => {
this.dialog.open(OkDialog, {
data: {
title: request.title,
message: request.message
},
panelClass: 'medium-dialog'
});
})
),
{ dispatch: false }
);
}

View File

@ -0,0 +1,71 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createReducer, on } from '@ngrx/store';
import { ProvenanceEventListingState } from './index';
import {
clearProvenanceRequest,
loadProvenanceOptionsSuccess,
pollProvenanceQuerySuccess,
provenanceApiError,
saveProvenanceRequest,
submitProvenanceQuery,
submitProvenanceQuerySuccess
} from './provenance-event-listing.actions';
export const initialState: ProvenanceEventListingState = {
options: null,
request: null,
provenance: null,
saving: false,
loadedTimestamp: '',
error: null,
status: 'pending'
};
export const provenanceEventListingReducer = createReducer(
initialState,
on(loadProvenanceOptionsSuccess, (state, { response }) => ({
...state,
options: response.provenanceOptions
})),
on(submitProvenanceQuery, (state) => ({
...state,
status: 'loading' as const
})),
on(submitProvenanceQuerySuccess, pollProvenanceQuerySuccess, (state, { response }) => ({
...state,
provenance: response.provenance,
loadedTimestamp: response.provenance.results.generated,
error: null,
status: 'success' as const
})),
on(saveProvenanceRequest, (state, { request }) => ({
...state,
request
})),
on(clearProvenanceRequest, (state) => ({
...state,
request: null
})),
on(provenanceApiError, (state, { error }) => ({
...state,
saving: false,
error,
status: 'error' as const
}))
);

View File

@ -0,0 +1,86 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createSelector } from '@ngrx/store';
import { ProvenanceState, selectProvenanceState } from '../index';
import {
Provenance,
provenanceEventListingFeatureKey,
ProvenanceEventListingState,
ProvenanceQueryParams,
ProvenanceRequest,
ProvenanceResults
} from './index';
import { selectCurrentRoute } from '../../../../state/router/router.selectors';
export const selectProvenanceEventListingState = createSelector(
selectProvenanceState,
(state: ProvenanceState) => state[provenanceEventListingFeatureKey]
);
export const selectSearchableFieldsFromRoute = createSelector(selectCurrentRoute, (route) => {
if (route) {
const queryParameters: ProvenanceQueryParams = {};
if (route.queryParams.componentId) {
queryParameters.componentId = route.queryParams.componentId;
}
if (route.queryParams.flowFileUuid) {
queryParameters.flowFileUuid = route.queryParams.flowFileUuid;
}
return queryParameters;
}
return null;
});
export const selectProvenanceRequest = createSelector(
selectProvenanceEventListingState,
(state: ProvenanceEventListingState) => state.request
);
export const selectStatus = createSelector(
selectProvenanceEventListingState,
(state: ProvenanceEventListingState) => state.status
);
export const selectProvenanceOptions = createSelector(
selectProvenanceEventListingState,
(state: ProvenanceEventListingState) => state.options
);
export const selectLoadedTimestamp = createSelector(
selectProvenanceEventListingState,
(state: ProvenanceEventListingState) => state.loadedTimestamp
);
export const selectProvenance = createSelector(
selectProvenanceEventListingState,
(state: ProvenanceEventListingState) => state.provenance
);
export const selectProvenanceId = createSelector(selectProvenance, (state: Provenance | null) => state?.id);
export const selectClusterNodeId = createSelector(
selectProvenanceRequest,
(state: ProvenanceRequest | null) => state?.clusterNodeId
);
export const selectProvenanceResults = createSelector(selectProvenance, (state: Provenance | null) => state?.results);
export const selectTimeOffset = createSelector(
selectProvenanceResults,
(state: ProvenanceResults | undefined) => state?.timeOffset
);

View File

@ -0,0 +1,33 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProvenanceEventListing } from './provenance-event-listing.component';
const routes: Routes = [
{
path: '',
component: ProvenanceEventListing
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ProvenanceEventListingRoutingModule {}

View File

@ -0,0 +1,43 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="flex flex-col h-full gap-y-2 text-sm" *ngIf="status$ | async; let status">
<div class="flex-1">
<ng-container *ngIf="provenance$ | async as provenance; else initialLoading">
<provenance-event-table
[events]="provenance.results.provenanceEvents"
[oldestEventAvailable]="provenance.results.oldestEvent"
[resultsMessage]="getResultsMessage(provenance)"
[hasRequest]="hasRequest(provenance.request)"
(openSearchCriteria)="openSearchCriteria()"
(openEventDialog)="openEventDialog($event)"
(clearRequest)="clearRequest()"></provenance-event-table>
</ng-container>
<ng-template #initialLoading>
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</ng-template>
</div>
<div class="flex justify-between">
<div class="refresh-container flex items-center gap-x-2">
<button class="nifi-button" (click)="refreshParameterContextListing()">
<i class="fa fa-refresh" [class.fa-spin]="(status$ | async) === 'loading'"></i>
</button>
<div>Last updated:</div>
<div class="refresh-timestamp">{{ loadedTimestamp$ | async }}</div>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

View File

@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProvenanceEventListing } from './provenance-event-listing.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/provenance-event-listing/provenance-event-listing.reducer';
describe('ParameterContextListing', () => {
let component: ProvenanceEventListing;
let fixture: ComponentFixture<ProvenanceEventListing>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ProvenanceEventListing],
providers: [provideMockStore({ initialState })]
});
fixture = TestBed.createComponent(ProvenanceEventListing);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,177 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import {
Provenance,
ProvenanceEventListingState,
ProvenanceRequest,
ProvenanceResults
} from '../../state/provenance-event-listing';
import {
selectLoadedTimestamp,
selectProvenance,
selectProvenanceRequest,
selectSearchableFieldsFromRoute,
selectStatus
} from '../../state/provenance-event-listing/provenance-event-listing.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter, map, take, tap } from 'rxjs';
import {
clearProvenanceRequest,
openProvenanceEventDialog,
openSearchDialog,
resubmitProvenanceQuery,
saveProvenanceRequest
} from '../../state/provenance-event-listing/provenance-event-listing.actions';
import { ProvenanceSearchDialog } from './provenance-search-dialog/provenance-search-dialog.component';
import { ProvenanceEventSummary } from '../../../../state/shared';
@Component({
selector: 'provenance-event-listing',
templateUrl: './provenance-event-listing.component.html',
styleUrls: ['./provenance-event-listing.component.scss']
})
export class ProvenanceEventListing {
status$ = this.store.select(selectStatus);
loadedTimestamp$ = this.store.select(selectLoadedTimestamp);
provenance$ = this.store.select(selectProvenance);
request!: ProvenanceRequest;
constructor(private store: Store<ProvenanceEventListingState>) {
this.store
.select(selectSearchableFieldsFromRoute)
.pipe(
filter((queryParams) => queryParams != null),
// only consider the first searchable fields from route, subsequent changes to the route will be present in the saved request
take(1),
map((queryParams) => {
const request: ProvenanceRequest = {
incrementalResults: false,
maxResults: 1000,
summarize: true
};
if (queryParams) {
if (queryParams.componentId || queryParams.flowFileUuid) {
request.searchTerms = {};
if (queryParams.componentId) {
request.searchTerms['ProcessorID'] = {
value: queryParams.componentId,
inverse: false
};
}
if (queryParams.flowFileUuid) {
request.searchTerms['FlowFileUUID'] = {
value: queryParams.flowFileUuid,
inverse: false
};
}
}
}
return request;
})
)
.subscribe((request) => {
this.store.dispatch(
saveProvenanceRequest({
request
})
);
});
// any changes to the saved request will trigger a provenance query submission
this.store
.select(selectProvenanceRequest)
.pipe(
map((request) => {
if (request) {
return request;
}
const initialRequest: ProvenanceRequest = {
incrementalResults: false,
maxResults: 1000,
summarize: true
};
return initialRequest;
}),
tap((request) => (this.request = request)),
takeUntilDestroyed()
)
.subscribe((request) => {
this.store.dispatch(
resubmitProvenanceQuery({
request
})
);
});
}
getResultsMessage(provenance: Provenance): string {
const request: ProvenanceRequest = provenance.request;
const results: ProvenanceResults = provenance.results;
if (this.hasRequest(request)) {
if (results.totalCount >= ProvenanceSearchDialog.MAX_RESULTS) {
return `Showing ${ProvenanceSearchDialog.MAX_RESULTS} of ${results.totalCount} events that match the specified query, please refine the search.`;
} else {
return 'Showing the events that match the specified query.';
}
} else {
if (results.totalCount >= ProvenanceSearchDialog.MAX_RESULTS) {
return `Showing the most recent ${ProvenanceSearchDialog.MAX_RESULTS} of ${results.totalCount} events, please refine the search.`;
} else {
return 'Showing the most recent events.';
}
}
}
hasRequest(request: ProvenanceRequest): boolean {
const hasSearchTerms: boolean = !!request.searchTerms && Object.keys(request.searchTerms).length > 0;
return !!request.startDate || !!request.endDate || hasSearchTerms;
}
clearRequest(): void {
this.store.dispatch(clearProvenanceRequest());
}
openSearchCriteria(): void {
this.store.dispatch(openSearchDialog());
}
openEventDialog(event: ProvenanceEventSummary): void {
this.store.dispatch(
openProvenanceEventDialog({
id: event.id
})
);
}
refreshParameterContextListing(): void {
this.store.dispatch(
resubmitProvenanceQuery({
request: this.request
})
);
}
}

View File

@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProvenanceEventListing } from './provenance-event-listing.component';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { NifiTooltipDirective } from '../../../../ui/common/tooltips/nifi-tooltip.directive';
import { ProvenanceEventListingRoutingModule } from './provenance-event-listing-routing.module';
import { ProvenanceEventTable } from './provenance-event-table/provenance-event-table.component';
@NgModule({
declarations: [ProvenanceEventListing],
exports: [ProvenanceEventListing],
imports: [
CommonModule,
ProvenanceEventListingRoutingModule,
NgxSkeletonLoaderModule,
MatSortModule,
MatTableModule,
NifiTooltipDirective,
ProvenanceEventTable
]
})
export class ProvenanceEventListingModule {}

View File

@ -0,0 +1,156 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="provenance-event-table h-full flex flex-col">
<div class="flex flex-col">
<div class="value font-bold">Displaying {{ filteredCount }} of {{ totalCount }}</div>
<div class="flex justify-between">
<div>
Oldest event available: <span class="value">{{ oldestEventAvailable }}</span>
</div>
<div>
{{ resultsMessage }}
<a *ngIf="hasRequest" (click)="clearRequestClicked()">Clear Search</a>
</div>
</div>
<div class="flex justify-between">
<form [formGroup]="filterForm">
<div class="flex pt-2">
<div class="mr-2">
<mat-form-field>
<mat-label>Filter</mat-label>
<input matInput type="text" class="small" formControlName="filterTerm" />
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Filter By</mat-label>
<mat-select formControlName="filterColumn">
<mat-option *ngFor="let option of filterColumnOptions" [value]="option"
>{{ option }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</form>
<div class="flex flex-col justify-center">
<button class="nifi-button" (click)="searchClicked()">
<i class="fa fa-search"></i>
</button>
</div>
</div>
</div>
<div class="flex-1 relative">
<div class="listing-table border absolute inset-0 overflow-y-auto">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortDisableClear
(matSortChange)="updateSort($event)"
[matSortActive]="sort.active"
[matSortDirection]="sort.direction">
<!-- More Details Column -->
<ng-container matColumnDef="moreDetails">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<div
class="pointer fa fa-info-circle"
title="View Details"
(click)="viewDetailsClicked(item)"></div>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="eventTime">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Event Time</th>
<td mat-cell *matCellDef="let item">
{{ item.eventTime }}
</td>
</ng-container>
<!-- Type Column -->
<ng-container matColumnDef="eventType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Type</th>
<td mat-cell *matCellDef="let item">
{{ item.eventType }}
</td>
</ng-container>
<!-- FlowFile UUID Column -->
<ng-container matColumnDef="flowFileUuid">
<th mat-header-cell *matHeaderCellDef mat-sort-header>FlowFile UUID</th>
<td mat-cell *matCellDef="let item">
{{ item.flowFileUuid }}
</td>
</ng-container>
<!-- File Size Column -->
<ng-container matColumnDef="fileSize">
<th mat-header-cell *matHeaderCellDef mat-sort-header>File Size</th>
<td mat-cell *matCellDef="let item">
{{ item.fileSize }}
</td>
</ng-container>
<!-- Component Name Column -->
<ng-container matColumnDef="componentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Component Name</th>
<td mat-cell *matCellDef="let item">
{{ item.componentName }}
</td>
</ng-container>
<!-- Component Type Column -->
<ng-container matColumnDef="componentType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Component Type</th>
<td mat-cell *matCellDef="let item">
{{ item.componentType }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<div class="flex items-center gap-x-3">
<!-- <div-->
<!-- class="pointer icon icon-lineage"-->
<!-- *ngIf="canStop(item)"-->
<!-- (click)="stopClicked(item)"-->
<!-- title="Stop"></div>-->
<div
*ngIf="supportsGoTo(item)"
class="pointer fa fa-long-arrow-right"
title="Go To"
[routerLink]="getComponentLink(item)"></div>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
(click)="select(row)"
[class.selected]="isSelected(row)"
[class.even]="even"></tr>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.provenance-event-table {
.listing-table {
table {
.mat-column-actions {
min-width: 50px;
width: 50px;
}
}
}
}

View File

@ -0,0 +1,40 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProvenanceEventTable } from './provenance-event-table.component';
import { MatTableModule } from '@angular/material/table';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('ProvenanceEventTable', () => {
let component: ProvenanceEventTable;
let fixture: ComponentFixture<ProvenanceEventTable>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProvenanceEventTable, MatTableModule, BrowserAnimationsModule]
});
fixture = TestBed.createComponent(ProvenanceEventTable);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,232 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatSortModule, Sort } from '@angular/material/sort';
import { TextTip } from '../../../../../ui/common/tooltips/text-tip/text-tip.component';
import { BulletinsTip } from '../../../../../ui/common/tooltips/bulletins-tip/bulletins-tip.component';
import { ValidationErrorsTip } from '../../../../../ui/common/tooltips/validation-errors-tip/validation-errors-tip.component';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgForOf, NgIf } from '@angular/common';
import { debounceTime } from 'rxjs';
import { ProvenanceEventSummary } from '../../../../../state/shared';
import { RouterLink } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
selector: 'provenance-event-table',
standalone: true,
templateUrl: './provenance-event-table.component.html',
imports: [
MatTableModule,
MatSortModule,
MatFormFieldModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
ReactiveFormsModule,
NgForOf,
NgIf,
RouterLink,
NgxSkeletonLoaderModule
],
styleUrls: ['./provenance-event-table.component.scss', '../../../../../../assets/styles/listing-table.scss']
})
export class ProvenanceEventTable implements AfterViewInit {
@Input() set events(events: ProvenanceEventSummary[]) {
if (events) {
this.dataSource.data = this.sortEvents(events, this.sort);
this.dataSource.filterPredicate = (data: ProvenanceEventSummary, filter: string) => {
const filterArray = filter.split('|');
const filterTerm = filterArray[0];
const filterColumn = filterArray[1];
if (filterColumn === this.filterColumnOptions[0]) {
return data.componentName.toLowerCase().indexOf(filterTerm.toLowerCase()) >= 0;
} else if (filterColumn === this.filterColumnOptions[1]) {
return data.componentType.toLowerCase().indexOf(filterTerm.toLowerCase()) >= 0;
} else {
return data.eventType.toLowerCase().indexOf(filterTerm.toLowerCase()) >= 0;
}
};
this.totalCount = events.length;
this.filteredCount = events.length;
// apply any filtering to the new data
const filterTerm = this.filterForm.get('filterTerm')?.value;
if (filterTerm?.length > 0) {
const filterColumn = this.filterForm.get('filterColumn')?.value;
this.applyFilter(filterTerm, filterColumn);
}
}
}
@Input() oldestEventAvailable!: string;
@Input() resultsMessage!: string;
@Input() hasRequest!: boolean;
@Output() openSearchCriteria: EventEmitter<void> = new EventEmitter<void>();
@Output() clearRequest: EventEmitter<void> = new EventEmitter<void>();
@Output() openEventDialog: EventEmitter<ProvenanceEventSummary> = new EventEmitter<ProvenanceEventSummary>();
protected readonly TextTip = TextTip;
protected readonly BulletinsTip = BulletinsTip;
protected readonly ValidationErrorsTip = ValidationErrorsTip;
// TODO - conditionally include the cluster column
displayedColumns: string[] = [
'moreDetails',
'eventTime',
'eventType',
'flowFileUuid',
'fileSize',
'componentName',
'componentType',
'actions'
];
dataSource: MatTableDataSource<ProvenanceEventSummary> = new MatTableDataSource<ProvenanceEventSummary>();
selectedEventId: string | null = null;
sort: Sort = {
active: 'eventTime',
direction: 'desc'
};
filterForm: FormGroup;
filterColumnOptions: string[] = ['component name', 'component type', 'type'];
totalCount: number = 0;
filteredCount: number = 0;
constructor(private formBuilder: FormBuilder) {
this.filterForm = this.formBuilder.group({ filterTerm: '', filterColumn: this.filterColumnOptions[0] });
}
ngAfterViewInit(): void {
this.filterForm
.get('filterTerm')
?.valueChanges.pipe(debounceTime(500))
.subscribe((filterTerm: string) => {
const filterColumn = this.filterForm.get('filterColumn')?.value;
this.applyFilter(filterTerm, filterColumn);
});
this.filterForm.get('filterColumn')?.valueChanges.subscribe((filterColumn: string) => {
const filterTerm = this.filterForm.get('filterTerm')?.value;
this.applyFilter(filterTerm, filterColumn);
});
}
updateSort(sort: Sort): void {
this.sort = sort;
this.dataSource.data = this.sortEvents(this.dataSource.data, sort);
}
sortEvents(events: ProvenanceEventSummary[], sort: Sort): ProvenanceEventSummary[] {
const data: ProvenanceEventSummary[] = events.slice();
return data.sort((a, b) => {
const isAsc = sort.direction === 'asc';
switch (sort.active) {
case 'eventTime':
// event ideas are increasing, so we can use this simple number for sorting purposes
// since we don't surface the timestamp as millis
return (a.eventId - b.eventId) * (isAsc ? 1 : -1);
case 'eventType':
return this.compare(a.eventType, b.eventType, isAsc);
case 'flowFileUuid':
return this.compare(a.flowFileUuid, b.flowFileUuid, isAsc);
case 'fileSize':
return (a.fileSizeBytes - b.fileSizeBytes) * (isAsc ? 1 : -1);
case 'componentName':
return this.compare(a.componentName, b.componentName, isAsc);
case 'componentType':
return this.compare(a.componentType, b.componentType, isAsc);
default:
return 0;
}
});
}
private compare(a: string, b: string, isAsc: boolean): number {
if (a === b) {
return 0;
}
return (a < b ? -1 : 1) * (isAsc ? 1 : -1);
}
applyFilter(filterTerm: string, filterColumn: string) {
this.dataSource.filter = `${filterTerm}|${filterColumn}`;
this.filteredCount = this.dataSource.filteredData.length;
}
clearRequestClicked(): void {
this.clearRequest.next();
}
searchClicked() {
this.openSearchCriteria.next();
}
viewDetailsClicked(event: ProvenanceEventSummary) {
this.openEventDialog.next(event);
}
select(event: ProvenanceEventSummary): void {
this.selectedEventId = event.id;
}
isSelected(event: ProvenanceEventSummary): boolean {
if (this.selectedEventId) {
return event.id == this.selectedEventId;
}
return false;
}
supportsGoTo(event: ProvenanceEventSummary): boolean {
if (event.groupId == null) {
return false;
}
if (event.componentId === 'Remote Output Port' || event.componentId === 'Remote Input Port') {
return false;
}
return true;
}
getComponentLink(event: ProvenanceEventSummary): string[] {
let link: string[];
if (event.groupId == event.componentId) {
link = ['/process-groups', event.componentId];
} else if (event.componentId === 'Connection' || event.componentId === 'Load Balanced Connection') {
link = ['/process-groups', event.groupId, 'Connection', event.componentId];
} else if (event.componentId === 'Output Port') {
link = ['/process-groups', event.groupId, 'OutputPort', event.componentId];
} else {
link = ['/process-groups', event.groupId, 'Processor', event.componentId];
}
return link;
}
}

View File

@ -0,0 +1,76 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Search Events</h2>
<form class="search-events-form" [formGroup]="provenanceOptionsForm">
<mat-dialog-content>
<ng-container *ngFor="let searchableField of request.options.searchableFields">
<div [formGroupName]="searchableField.id">
<mat-form-field>
<mat-label>{{ searchableField.label }}</mat-label>
<input matInput formControlName="value" type="text" />
</mat-form-field>
<div class="-mt-6 mb-4">
<mat-checkbox formControlName="inverse" name="inverse"> Exclude </mat-checkbox>
</div>
</div>
</ng-container>
<mat-form-field>
<mat-label>Date Range</mat-label>
<mat-date-range-input [rangePicker]="picker">
<input matStartDate formControlName="startDate" placeholder="Start date" />
<input matEndDate formControlName="endDate" placeholder="End date" />
</mat-date-range-input>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-date-range-picker #picker></mat-date-range-picker>
</mat-form-field>
<div class="flex gap-x-3">
<div class="w-full flex flex-col">
<mat-form-field>
<mat-label>Start Time ({{ timezone }})</mat-label>
<input matInput type="text" formControlName="startTime" />
</mat-form-field>
<mat-form-field>
<mat-label>Minimum File Size</mat-label>
<input matInput type="text" formControlName="minFileSize" />
</mat-form-field>
</div>
<div class="w-full flex flex-col">
<mat-form-field>
<mat-label>End Time ({{ timezone }})</mat-label>
<input matInput type="text" formControlName="endTime" />
</mat-form-field>
<mat-form-field>
<mat-label>Maximum File Size</mat-label>
<input matInput type="text" formControlName="maxFileSize" />
</mat-form-field>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button color="accent" mat-raised-button mat-dialog-close>Cancel</button>
<button
[disabled]="provenanceOptionsForm.invalid"
type="button"
color="primary"
(click)="searchClicked()"
mat-raised-button
mat-dialog-close>
<span>Search</span>
</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,30 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.search-events-form {
@include mat.button-density(-1);
.mat-mdc-form-field {
width: 100%;
}
.mdc-text-field__input::-webkit-calendar-picker-indicator {
display: block !important;
}
}

View File

@ -0,0 +1,85 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProvenanceSearchDialog } from './provenance-search-dialog.component';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatNativeDateModule } from '@angular/material/core';
describe('ProvenanceSearchDialog', () => {
let component: ProvenanceSearchDialog;
let fixture: ComponentFixture<ProvenanceSearchDialog>;
const data: any = {
timeOffset: -18000000,
options: {
searchableFields: [
{
id: 'EventType',
field: 'eventType',
label: 'Event Type',
type: 'STRING'
},
{
id: 'FlowFileUUID',
field: 'uuid',
label: 'FlowFile UUID',
type: 'STRING'
},
{
id: 'Filename',
field: 'filename',
label: 'Filename',
type: 'STRING'
},
{
id: 'ProcessorID',
field: 'processorId',
label: 'Component ID',
type: 'STRING'
},
{
id: 'Relationship',
field: 'relationship',
label: 'Relationship',
type: 'STRING'
}
]
},
currentRequest: {
incrementalResults: false,
maxResults: 1000,
summarize: true
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProvenanceSearchDialog, BrowserAnimationsModule, MatNativeDateModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(ProvenanceSearchDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,220 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
import {
ProvenanceRequest,
ProvenanceSearchDialogRequest,
SearchableField
} from '../../../state/provenance-event-listing';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
@Component({
selector: 'provenance-search-dialog',
standalone: true,
templateUrl: './provenance-search-dialog.component.html',
imports: [
ReactiveFormsModule,
MatDialogModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
NgIf,
AsyncPipe,
NgForOf,
MatDatepickerModule
],
styleUrls: ['./provenance-search-dialog.component.scss']
})
export class ProvenanceSearchDialog {
@Input() timezone!: string;
@Output() submitSearchCriteria: EventEmitter<ProvenanceRequest> = new EventEmitter<ProvenanceRequest>();
public static readonly MAX_RESULTS: number = 1000;
private static readonly DEFAULT_START_TIME: string = '00:00:00';
private static readonly DEFAULT_END_TIME: string = '23:59:59';
provenanceOptionsForm: FormGroup;
constructor(
@Inject(MAT_DIALOG_DATA) public request: ProvenanceSearchDialogRequest,
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon
) {
const now = new Date();
this.clearTime(now);
let startDate: Date = now;
let startTime: string = ProvenanceSearchDialog.DEFAULT_START_TIME;
let endDate: Date = now;
let endTime: string = ProvenanceSearchDialog.DEFAULT_END_TIME;
let minFileSize: string = '';
let maxFileSize: string = '';
if (request.currentRequest) {
const requestedStartDate = request.currentRequest.startDate;
if (requestedStartDate) {
startDate = this.nifiCommon.parseDateTime(requestedStartDate);
this.clearTime(startDate);
const requestedStartDateTime = requestedStartDate?.split(' ');
if (requestedStartDateTime && requestedStartDateTime.length > 1) {
startTime = requestedStartDateTime[1];
}
}
const requestedEndDate = request.currentRequest.endDate;
if (requestedEndDate) {
endDate = this.nifiCommon.parseDateTime(requestedEndDate);
this.clearTime(endDate);
const requestedEndDateTime = requestedEndDate?.split(' ');
if (requestedEndDateTime && requestedEndDateTime.length > 0) {
endTime = requestedEndDateTime[1];
}
}
if (request.currentRequest.minimumFileSize) {
minFileSize = request.currentRequest.minimumFileSize;
}
if (request.currentRequest.maximumFileSize) {
maxFileSize = request.currentRequest.maximumFileSize;
}
}
this.provenanceOptionsForm = this.formBuilder.group({
startDate: new FormControl(startDate),
startTime: new FormControl(startTime),
endDate: new FormControl(endDate),
endTime: new FormControl(endTime),
minFileSize: new FormControl(minFileSize),
maxFileSize: new FormControl(maxFileSize)
});
const searchTerms: any = request.currentRequest?.searchTerms;
const searchableFields: SearchableField[] = request.options.searchableFields;
searchableFields.forEach((searchableField) => {
let value: string = '';
let inverse: boolean = false;
if (searchTerms && searchTerms[searchableField.id]) {
value = searchTerms[searchableField.id].value;
inverse = searchTerms[searchableField.id].inverse;
}
this.provenanceOptionsForm.addControl(
searchableField.id,
this.formBuilder.group({
value: new FormControl(value),
inverse: new FormControl(inverse)
})
);
});
}
private clearTime(date: Date): void {
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
}
searchClicked() {
const provenanceRequest: ProvenanceRequest = {
maxResults: ProvenanceSearchDialog.MAX_RESULTS,
summarize: true,
incrementalResults: false
};
const startDate: Date = this.provenanceOptionsForm.get('startDate')?.value;
if (startDate) {
// convert the date object into the format the api expects
const formatted: string = this.nifiCommon.formatDateTime(startDate);
// get just the date portion because the time is entered separately by the user
const formattedStartDateTime = formatted.split(' ');
if (formattedStartDateTime.length > 0) {
const formattedStartDate = formattedStartDateTime[0];
let startTime: string = this.provenanceOptionsForm.get('startTime')?.value;
if (!startTime) {
startTime = ProvenanceSearchDialog.DEFAULT_START_TIME;
}
// combine all three pieces into the format the api requires
provenanceRequest.startDate = `${formattedStartDate} ${startTime} ${this.timezone}`;
}
}
const endDate: Date = this.provenanceOptionsForm.get('endDate')?.value;
if (endDate) {
// convert the date object into the format the api expects
const formatted: string = this.nifiCommon.formatDateTime(endDate);
// get just the date portion because the time is entered separately by the user
const formattedEndDateTime = formatted.split(' ');
if (formattedEndDateTime.length > 0) {
const formattedEndDate = formattedEndDateTime[0];
let endTime: string = this.provenanceOptionsForm.get('endTime')?.value;
if (!endTime) {
endTime = ProvenanceSearchDialog.DEFAULT_END_TIME;
}
// combine all three pieces into the format the api requires
provenanceRequest.endDate = `${formattedEndDate} ${endTime} ${this.timezone}`;
}
}
const minFileSize: string = this.provenanceOptionsForm.get('minFileSize')?.value;
if (minFileSize) {
provenanceRequest.minimumFileSize = minFileSize;
}
const maxFileSize: string = this.provenanceOptionsForm.get('maxFileSize')?.value;
if (maxFileSize) {
provenanceRequest.maximumFileSize = maxFileSize;
}
const searchTerms: any = {};
const searchableFields: SearchableField[] = this.request.options.searchableFields;
searchableFields.forEach((searchableField) => {
// @ts-ignore
const searchableFieldForm: FormGroup = this.provenanceOptionsForm.get(searchableField.id);
if (searchableFieldForm) {
const searchableFieldValue: string = searchableFieldForm.get('value')?.value;
if (searchableFieldValue) {
searchTerms[searchableField.id] = {
value: searchableFieldValue,
inverse: searchableFieldForm.get('inverse')?.value
};
}
}
});
provenanceRequest.searchTerms = searchTerms;
this.submitSearchCriteria.next(provenanceRequest);
}
}

View File

@ -0,0 +1,31 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class AboutService {
private static readonly API: string = '../nifi-api';
constructor(private httpClient: HttpClient) {}
getAbout(): Observable<any> {
return this.httpClient.get(`${AboutService.API}/flow/about`);
}
}

View File

@ -21,6 +21,11 @@ import { Injectable } from '@angular/core';
providedIn: 'root'
})
export class NiFiCommon {
private static readonly MILLIS_PER_DAY: number = 86400000;
private static readonly MILLIS_PER_HOUR: number = 3600000;
private static readonly MILLIS_PER_MINUTE: number = 60000;
private static readonly MILLIS_PER_SECOND: number = 1000;
constructor() {}
/**
@ -171,4 +176,144 @@ export class NiFiCommon {
}
return NiFiCommon.LEAD_TRAIL_WHITE_SPACE_REGEX.test(value);
}
/**
* Pads the specified value to the specified width with the specified character.
* If the specified value is already wider than the specified width, the original
* value is returned.
*
* @param {integer} value
* @param {integer} width
* @param {string} character
* @returns {string}
*/
pad(value: number, width: number, character: string): string {
let s: string = value + '';
// pad until wide enough
while (s.length < width) {
s = character + s;
}
return s;
}
/**
* Formats the specified DateTime.
*
* @param {Date} date
* @returns {String}
*/
formatDateTime(date: Date): string {
return (
this.pad(date.getMonth() + 1, 2, '0') +
'/' +
this.pad(date.getDate(), 2, '0') +
'/' +
this.pad(date.getFullYear(), 2, '0') +
' ' +
this.pad(date.getHours(), 2, '0') +
':' +
this.pad(date.getMinutes(), 2, '0') +
':' +
this.pad(date.getSeconds(), 2, '0') +
'.' +
this.pad(date.getMilliseconds(), 3, '0')
);
}
/**
* Parses the specified date time into a Date object. The resulting
* object does not account for timezone and should only be used for
* performing relative comparisons.
*
* @param {string} rawDateTime
* @returns {Date}
*/
parseDateTime(rawDateTime: string): Date {
// handle non date values
if (!rawDateTime) {
return new Date();
}
// parse the date time
const dateTime: string[] = rawDateTime.split(/ /);
// ensure the correct number of tokens
if (dateTime.length !== 3) {
return new Date();
}
// get the date and time
const date: string[] = dateTime[0].split(/\//);
const time: string[] = dateTime[1].split(/:/);
// ensure the correct number of tokens
if (date.length !== 3 || time.length !== 3) {
return new Date();
}
const year: number = parseInt(date[2], 10);
const month: number = parseInt(date[0], 10) - 1; // new Date() accepts months 0 - 11
const day: number = parseInt(date[1], 10);
const hours: number = parseInt(time[0], 10);
const minutes: number = parseInt(time[1], 10);
// detect if there is millis
const secondsSpec: string[] = time[2].split(/\./);
const seconds: number = parseInt(secondsSpec[0], 10);
let milliseconds: number = 0;
if (secondsSpec.length === 2) {
milliseconds = parseInt(secondsSpec[1], 10);
}
return new Date(year, month, day, hours, minutes, seconds, milliseconds);
}
/**
* Formats the specified duration.
*
* @param {number} millis in millis
*/
formatDuration(millis: number): string {
// don't support sub millisecond resolution
let duration: number = millis < 1 ? 0 : millis;
// determine the number of days in the specified duration
let days: number = duration / NiFiCommon.MILLIS_PER_DAY;
days = days >= 1 ? Math.trunc(days) : 0;
duration %= NiFiCommon.MILLIS_PER_DAY;
// remaining duration should be less than 1 day, get number of hours
let hours: number = duration / NiFiCommon.MILLIS_PER_HOUR;
hours = hours >= 1 ? Math.trunc(hours) : 0;
duration %= NiFiCommon.MILLIS_PER_HOUR;
// remaining duration should be less than 1 hour, get number of minutes
let minutes: number = duration / NiFiCommon.MILLIS_PER_MINUTE;
minutes = minutes >= 1 ? Math.trunc(minutes) : 0;
duration %= NiFiCommon.MILLIS_PER_MINUTE;
// remaining duration should be less than 1 minute, get number of seconds
let seconds: number = duration / NiFiCommon.MILLIS_PER_SECOND;
seconds = seconds >= 1 ? Math.trunc(seconds) : 0;
// remaining duration is the number millis (don't support sub millisecond resolution)
duration = Math.floor(duration % NiFiCommon.MILLIS_PER_SECOND);
// format the time
const time =
this.pad(hours, 2, '0') +
':' +
this.pad(minutes, 2, '0') +
':' +
this.pad(seconds, 2, '0') +
'.' +
this.pad(duration, 3, '0');
// only include days if appropriate
if (days > 0) {
return days + ' days and ' + time;
} else {
return time;
}
}
}

View File

@ -0,0 +1,27 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createAction, props } from '@ngrx/store';
import { LoadAboutResponse } from './index';
export const loadAbout = createAction('[About] Load About');
export const loadAboutSuccess = createAction('[About] Load About Success', props<{ response: LoadAboutResponse }>());
export const aboutApiError = createAction('[About] About Api Error', props<{ error: string }>());
export const clearAboutApiError = createAction('[User] Clear About Api Error');

View File

@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as AboutActions from './about.actions';
import { catchError, from, map, of, switchMap } from 'rxjs';
import { AboutService } from '../../service/about.service';
@Injectable()
export class AboutEffects {
constructor(
private actions$: Actions,
private aboutService: AboutService
) {}
loadAbout$ = createEffect(() =>
this.actions$.pipe(
ofType(AboutActions.loadAbout),
switchMap(() => {
return from(
this.aboutService.getAbout().pipe(
map((response) =>
AboutActions.loadAboutSuccess({
response: response
})
),
catchError((error) => of(AboutActions.aboutApiError({ error: error.error })))
)
);
})
)
);
}

View File

@ -0,0 +1,50 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createReducer, on } from '@ngrx/store';
import { AboutState } from './index';
import { aboutApiError, clearAboutApiError, loadAbout, loadAboutSuccess } from './about.actions';
export const initialState: AboutState = {
about: null,
error: null,
status: 'pending'
};
export const aboutReducer = createReducer(
initialState,
on(loadAbout, (state) => ({
...state,
status: 'loading' as const
})),
on(loadAboutSuccess, (state, { response }) => ({
...state,
about: response.about,
error: null,
status: 'success' as const
})),
on(aboutApiError, (state, { error }) => ({
...state,
error: error,
status: 'error' as const
})),
on(clearAboutApiError, (state) => ({
...state,
error: null,
status: 'pending' as const
}))
);

View File

@ -0,0 +1,23 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { aboutFeatureKey, AboutState } from './index';
export const selectAboutState = createFeatureSelector<AboutState>(aboutFeatureKey);
export const selectAbout = createSelector(selectAboutState, (state: AboutState) => state.about);

View File

@ -0,0 +1,40 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const aboutFeatureKey = 'about';
export interface LoadAboutResponse {
about: About;
}
export interface About {
title: string;
version: string;
uri: string;
contentViewerUrl: string;
timezone: string;
buildTag: string;
buildRevision: string;
buildBranch: string;
buildTimestamp: string;
}
export interface AboutState {
about: About | null;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}

View File

@ -21,15 +21,19 @@ import { UserState, userFeatureKey } from './user';
import { userReducer } from './user/user.reducer';
import { extensionTypesFeatureKey, ExtensionTypesState } from './extension-types';
import { extensionTypesReducer } from './extension-types/extension-types.reducer';
import { aboutFeatureKey, AboutState } from './about';
import { aboutReducer } from './about/about.reducer';
export interface NiFiState {
router: RouterReducerState;
[userFeatureKey]: UserState;
[extensionTypesFeatureKey]: ExtensionTypesState;
[aboutFeatureKey]: AboutState;
}
export const rootReducers: ActionReducerMap<NiFiState> = {
router: routerReducer,
[userFeatureKey]: userReducer,
[extensionTypesFeatureKey]: extensionTypesReducer
[extensionTypesFeatureKey]: extensionTypesReducer,
[aboutFeatureKey]: aboutReducer
};

View File

@ -20,6 +20,11 @@ export interface OkDialogRequest {
message: string;
}
export interface CancelDialogRequest {
title: string;
message: string;
}
export interface YesNoDialogRequest {
title: string;
message: string;
@ -53,6 +58,65 @@ export interface EditControllerServiceDialogRequest {
controllerService: ControllerServiceEntity;
}
export interface ProvenanceEventSummary {
id: string;
eventId: number;
eventTime: string;
eventType: string;
flowFileUuid: string;
fileSize: string;
fileSizeBytes: number;
clusterNodeId?: string;
clusterNodeAddress?: string;
groupId: string;
componentId: string;
componentType: string;
componentName: string;
}
export interface Attribute {
name: string;
value: string;
previousValue: string;
}
export interface ProvenanceEvent extends ProvenanceEventSummary {
eventDuration: string;
lineageDuration: number;
clusterNodeId: string;
clusterNodeAddress: string;
sourceSystemFlowFileId: string;
alternateIdentifierUri: string;
attributes: Attribute[];
parentUuids: string[];
childUuids: string[];
transitUri: string;
relationship: string;
details: string;
contentEqual: boolean;
inputContentAvailable: boolean;
inputContentClaimSection: string;
inputContentClaimContainer: string;
inputContentClaimIdentifier: string;
inputContentClaimOffset: number;
inputContentClaimFileSize: string;
inputContentClaimFileSizeBytes: number;
outputContentAvailable: boolean;
outputContentClaimSection: string;
outputContentClaimContainer: string;
outputContentClaimIdentifier: string;
outputContentClaimOffset: string;
outputContentClaimFileSize: string;
outputContentClaimFileSizeBytes: number;
replayAvailable: boolean;
replayExplanation: string;
sourceConnectionIdentifier: string;
}
export interface ProvenanceEventDialogRequest {
event: ProvenanceEvent;
}
export interface TextTipInput {
text: string;
}

View File

@ -0,0 +1,24 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>{{ request.title }}</h2>
<mat-dialog-content>
<div class="text-sm">{{ request.message }}</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" (click)="cancelClicked()" mat-raised-button mat-dialog-close color="primary">Cancel</button>
</mat-dialog-actions>

View File

@ -0,0 +1,16 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

View File

@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CancelDialog } from './cancel-dialog.component';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
describe('CancelDialog', () => {
let component: CancelDialog;
let fixture: ComponentFixture<CancelDialog>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [CancelDialog],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'Title',
message: 'Message'
}
}
]
});
fixture = TestBed.createComponent(CancelDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Output } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { CancelDialogRequest } from '../../../state/shared';
@Component({
selector: 'cancel-dialog',
standalone: true,
imports: [MatDialogModule, MatButtonModule],
templateUrl: './cancel-dialog.component.html',
styleUrls: ['./cancel-dialog.component.scss']
})
export class CancelDialog {
@Output() cancel: EventEmitter<void> = new EventEmitter<void>();
constructor(@Inject(MAT_DIALOG_DATA) public request: CancelDialogRequest) {}
cancelClicked(): void {
this.cancel.next();
}
}

View File

@ -260,8 +260,8 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
type: property.descriptor.required
? 'required'
: property.descriptor.dynamic
? 'userDefined'
: 'optional'
? 'userDefined'
: 'optional'
};
this.populateServiceLink(item);
@ -307,8 +307,8 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor {
type: property.descriptor.required
? 'required'
: property.descriptor.dynamic
? 'userDefined'
: 'optional'
? 'userDefined'
: 'optional'
};
this.populateServiceLink(item);

View File

@ -0,0 +1,447 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>Provenance Event</h2>
<div class="provenance-event">
<mat-dialog-content>
<mat-tab-group>
<mat-tab label="Details">
<div class="tab-content py-4">
<div class="absolute inset-0 flex gap-x-4">
<div class="w-full flex flex-col gap-y-3">
<div class="flex flex-col">
<div>Time</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.eventTime }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Event Duration</div>
<ng-container
*ngTemplateOutlet="
formatDuration;
context: { $implicit: request.event.eventDuration }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Lineage Duration</div>
<ng-container
*ngTemplateOutlet="
formatDuration;
context: { $implicit: request.event.lineageDuration }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Type</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.eventType }
"></ng-container>
</div>
<div class="flex flex-col">
<div>FlowFile UUID</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.flowFileUuid }
"></ng-container>
</div>
<div class="flex flex-col">
<div>File Size</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: {
$implicit: request.event.fileSize,
title: request.event.fileSizeBytes + ' bytes'
}
"></ng-container>
</div>
<div class="flex flex-col">
<div>Component Id</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.componentId }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Component Name</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.componentName }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Component Type</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.componentType }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Details</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.details }
"></ng-container>
</div>
<ng-container *ngIf="request.event.eventType === 'RECEIVE'">
<div class="flex flex-col">
<div>Source FlowFile Id</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.sourceSystemFlowFileId }
"></ng-container>
</div>
<div class="flex flex-col">
<div>Transit Uri</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.transitUri }
"></ng-container>
</div>
</ng-container>
<ng-container *ngIf="request.event.eventType === 'SEND'">
<div class="flex flex-col">
<div>Transit Uri</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.transitUri }
"></ng-container>
</div>
</ng-container>
<ng-container *ngIf="request.event.eventType === 'ADDINFO'">
<div class="flex flex-col">
<div>Alternate Identifier Uri</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.alternateIdentifierUri }
"></ng-container>
</div>
</ng-container>
<ng-container *ngIf="request.event.eventType === 'ROUTE'">
<div class="flex flex-col">
<div>Relationship</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.relationship }
"></ng-container>
</div>
</ng-container>
<ng-container *ngIf="request.event.eventType === 'FETCH'">
<div class="flex flex-col" *ngIf="request.event.eventType === 'FETCH'">
<div>Transit Uri</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.transitUri }
"></ng-container>
</div>
</ng-container>
<ng-container *ngIf="request.event.clusterNodeId">
<div class="flex flex-col">
<div>Node Address</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: request.event.clusterNodeAddress }
"></ng-container>
</div>
</ng-container>
<ng-template #formatDuration let-duration>
<ng-container *ngIf="duration != null; else noDuration">
<div class="value">{{ formatDurationValue(duration) }}</div>
</ng-container>
<ng-template #noDuration>
<div class="unset">No value set</div>
</ng-template>
</ng-template>
</div>
<div class="w-full flex flex-col gap-y-3">
<div class="flex flex-col">
<div class="event-header">
Parent FlowFiles ({{ request.event.parentUuids.length }})
</div>
<ng-container
*ngTemplateOutlet="
formatUuids;
context: { $implicit: request.event.parentUuids, emptyMessage: 'No parents' }
"></ng-container>
</div>
<div class="flex flex-col">
<div class="event-header">Child FlowFiles ({{ request.event.childUuids.length }})</div>
<ng-container
*ngTemplateOutlet="
formatUuids;
context: { $implicit: request.event.childUuids, emptyMessage: 'No children' }
"></ng-container>
</div>
<ng-template #formatUuids let-uuids let-emptyMessage="emptyMessage">
<ng-container *ngIf="uuids.length > 0; else noUuids">
<div class="value">
<div *ngFor="let uuid of uuids">{{ uuid }}</div>
</div>
</ng-container>
<ng-template #noUuids>
<div class="unset">{{ emptyMessage }}</div>
</ng-template>
</ng-template>
</div>
</div>
</div>
</mat-tab>
<mat-tab label="Attributes">
<div class="tab-content py-4">
<div class="absolute inset-0 flex flex-col gap-y-4">
<div class="flex justify-between">
<div class="event-header">Attribute Values</div>
<div class="flex items-center gap-x-1">
<mat-checkbox [(ngModel)]="onlyShowModifiedAttributes"></mat-checkbox>
<div>Show modified attributes only</div>
</div>
</div>
<div class="flex flex-col">
<div *ngFor="let attribute of request.event.attributes">
<div *ngIf="shouldShowAttribute(attribute)" class="mb-4 flex flex-col">
<div>{{ attribute.name }}</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: attribute.value }
"></ng-container>
<ng-container *ngIf="attributeValueChanged(attribute)">
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: attribute.previousValue }
"></ng-container>
</ng-container>
</div>
</div>
</div>
</div>
</div>
</mat-tab>
<mat-tab label="Content">
<div class="tab-content py-4">
<div class="absolute inset-0 flex flex-col gap-y-4">
<div class="flex gap-x-4">
<div class="w-full">
<div class="event-header">Input Claim</div>
<div class="flex flex-col gap-y-3">
<div>
<div>Container</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.inputContentClaimContainer }
"></ng-container>
</div>
<div>
<div>Section</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.inputContentClaimSection }
"></ng-container>
</div>
<div>
<div>Identifier</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.inputContentClaimIdentifier }
"></ng-container>
</div>
<div>
<div>Offset</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.inputContentClaimOffset }
"></ng-container>
</div>
<div>
<div>Size</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: {
$implicit: request.event.inputContentClaimFileSize,
title: request.event.inputContentClaimFileSizeBytes + ' bytes'
}
"></ng-container>
</div>
<div *ngIf="request.event.inputContentAvailable" class="flex">
<button
color="accent"
mat-raised-button
(click)="downloadContentClicked('input')">
<i class="fa fa-download"></i>
Download
</button>
<button
*ngIf="contentViewerAvailable"
class="ml-3"
color="accent"
mat-raised-button
(click)="viewContentClicked('input')">
<i class="fa fa-eye"></i>
View
</button>
</div>
</div>
</div>
<div class="w-full">
<div class="event-header">Output Claim</div>
<div class="flex flex-col gap-y-3">
<div>
<div>Container</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.outputContentClaimContainer }
"></ng-container>
</div>
<div>
<div>Section</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.outputContentClaimSection }
"></ng-container>
</div>
<div>
<div>Identifier</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.outputContentClaimIdentifier }
"></ng-container>
</div>
<div>
<div>Offset</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.outputContentClaimOffset }
"></ng-container>
</div>
<div>
<div>Size</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: {
$implicit: request.event.outputContentClaimFileSize,
title: request.event.outputContentClaimFileSizeBytes + ' bytes'
}
"></ng-container>
</div>
<div *ngIf="request.event.outputContentAvailable" class="flex">
<button
color="accent"
mat-raised-button
(click)="downloadContentClicked('output')">
<i class="fa fa-download"></i>
Download
</button>
<button
*ngIf="contentViewerAvailable"
class="ml-3"
color="accent"
mat-raised-button
(click)="viewContentClicked('output')">
<i class="fa fa-eye"></i>
View
</button>
</div>
</div>
</div>
</div>
<div class="flex flex-col">
<div class="event-header">Replay</div>
<div
*ngIf="request.event.replayAvailable; else replayNotAvailable"
class="flex flex-col gap-y-3">
<div class="flex flex-col">
<div>Connection Id</div>
<ng-container
*ngTemplateOutlet="
formatContentValue;
context: { $implicit: request.event.sourceConnectionIdentifier }
"></ng-container>
</div>
<div>
<button color="accent" mat-raised-button mat-dialog-close (click)="replayClicked()">
<i class="fa fa-repeat"></i>
Replay
</button>
</div>
</div>
<ng-template #replayNotAvailable>
<div>{{ request.event.replayExplanation }}</div>
</ng-template>
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
<ng-template #formatValue let-value let-title="title">
<ng-container *ngIf="value != null; else nullValue">
<ng-container *ngIf="value === ''; else nonEmptyValue">
<div class="unset">Empty string set</div>
</ng-container>
<ng-template #nonEmptyValue>
<div class="value" *ngIf="title == null; else valueWithTitle">{{ value }}</div>
<ng-template #valueWithTitle>
<div class="value" [title]="title">{{ value }}</div>
</ng-template>
</ng-template>
</ng-container>
<ng-template #nullValue>
<div class="unset">No value set</div>
</ng-template>
</ng-template>
<ng-template #formatContentValue let-value let-title="title">
<ng-container *ngIf="value != null; else nullValue">
<div class="value" *ngIf="title == null; else valueWithTitle">{{ value }}</div>
<ng-template #valueWithTitle>
<div class="value" [title]="title">{{ value }}</div>
</ng-template>
</ng-container>
<ng-template #nullValue>
<div class="unset">No value previously set</div>
</ng-template>
</ng-template>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button color="primary" mat-raised-button mat-dialog-close>Ok</button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.provenance-event {
@include mat.button-density(-1);
.mdc-dialog__content {
padding: 0 16px;
font-size: 14px;
.tab-content {
position: relative;
height: 475px;
overflow-y: auto;
.event-header {
color: #728e9b;
font-size: 15px;
font-family: 'Roboto Slab';
font-style: normal;
font-weight: bold;
}
}
}
.mat-mdc-form-field {
width: 100%;
}
.mdc-text-field__input::-webkit-calendar-picker-indicator {
display: block !important;
}
}

View File

@ -0,0 +1,85 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProvenanceEventDialog } from './provenance-event-dialog.component';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('ProvenanceEventDialog', () => {
let component: ProvenanceEventDialog;
let fixture: ComponentFixture<ProvenanceEventDialog>;
const data: any = {
event: {
id: '67231',
eventId: 67231,
eventTime: '12/06/2023 13:24:14.934 EST',
lineageDuration: 80729691,
eventType: 'DROP',
flowFileUuid: '6908fd9d-9168-4da5-a92b-0414cdb5e3bc',
fileSize: '0 bytes',
fileSizeBytes: 0,
groupId: '36d207b9-018c-1000-6f5a-1b02d4517a78',
componentId: '36d293df-018c-1000-b438-9e0cd664f5aa',
componentType: 'Connection',
componentName: 'success',
attributes: [
{
name: 'filename',
value: '6908fd9d-9168-4da5-a92b-0414cdb5e3bc',
previousValue: '6908fd9d-9168-4da5-a92b-0414cdb5e3bc'
},
{
name: 'path',
value: './',
previousValue: './'
},
{
name: 'uuid',
value: '6908fd9d-9168-4da5-a92b-0414cdb5e3bc',
previousValue: '6908fd9d-9168-4da5-a92b-0414cdb5e3bc'
}
],
parentUuids: [],
childUuids: [],
details: 'FlowFile Queue emptied by admin',
contentEqual: false,
inputContentAvailable: false,
outputContentAvailable: false,
outputContentClaimFileSize: '0 bytes',
outputContentClaimFileSizeBytes: 0,
replayAvailable: true,
sourceConnectionIdentifier: '36d293df-018c-1000-b438-9e0cd664f5aa'
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProvenanceEventDialog, BrowserAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(ProvenanceEventDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,98 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { AsyncPipe, NgForOf, NgIf, NgTemplateOutlet } from '@angular/common';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { MatTabsModule } from '@angular/material/tabs';
import { Attribute, ProvenanceEvent, ProvenanceEventDialogRequest } from '../../../state/shared';
@Component({
selector: 'provenance-event-dialog',
standalone: true,
templateUrl: './provenance-event-dialog.component.html',
imports: [
ReactiveFormsModule,
MatDialogModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
NgIf,
AsyncPipe,
NgForOf,
MatDatepickerModule,
MatTabsModule,
NgTemplateOutlet,
FormsModule
],
styleUrls: ['./provenance-event-dialog.component.scss']
})
export class ProvenanceEventDialog {
@Input() contentViewerAvailable!: boolean;
@Output() downloadContent: EventEmitter<string> = new EventEmitter<string>();
@Output() viewContent: EventEmitter<string> = new EventEmitter<string>();
@Output() replay: EventEmitter<void> = new EventEmitter<void>();
onlyShowModifiedAttributes: boolean = false;
constructor(
@Inject(MAT_DIALOG_DATA) public request: ProvenanceEventDialogRequest,
private nifiCommon: NiFiCommon
) {}
formatDurationValue(duration: number): string {
if (duration === 0) {
return '< 1 sec';
}
return this.nifiCommon.formatDuration(duration);
}
attributeValueChanged(attribute: Attribute): boolean {
return attribute.value != attribute.previousValue;
}
shouldShowAttribute(attribute: Attribute): boolean {
// if the attribute value has changed, show it
if (this.attributeValueChanged(attribute)) {
return true;
}
// attribute value hasn't changed, only show when
// the user does not want to only see modified attributes
return !this.onlyShowModifiedAttributes;
}
downloadContentClicked(direction: string): void {
this.downloadContent.next(direction);
}
viewContentClicked(direction: string): void {
this.viewContent.next(direction);
}
replayClicked(): void {
this.replay.next();
}
}

View File

@ -20,11 +20,11 @@
@use '@angular/material' as mat;
// Plus imports for other components in your app.
@import '~roboto-fontface/css/roboto/roboto-fontface.css';
@import 'assets/fonts/flowfont/flowfont.css';
@import '~font-awesome/css/font-awesome.min.css';
@import '~codemirror/lib/codemirror.css';
@import '~codemirror/addon/hint/show-hint.css';
@use 'roboto-fontface/css/roboto/roboto-fontface.css';
@use 'assets/fonts/flowfont/flowfont.css';
@use 'font-awesome/css/font-awesome.min.css';
@use 'codemirror/lib/codemirror.css';
@use 'codemirror/addon/hint/show-hint.css';
$fontPrimary: 'Roboto', sans-serif;
$fontSecondary: 'Robot Slab', sans-serif;