[NIFI-12754] - Flow Configuration History (#8399)

* [NIFI-12754] - Flow Configuration History
* support selection
* support pagination
* support sorting
* added time controls, also updated provenance to use them too
* allow for clearing of filters
* use date range filter
* more details dialog for flow config history
* support purge history

* review feedback - use snackbar error where intended, add padding between header and page content

* don't use route for flow config history item selection

* Address review feedback

* remove unused style

* Review feedback * initial query is for the last 2 weeks * added timezone to purge confirmation message * reset pagination state on filter, clear filter, and purge * only resubmit query when filter by changes IF there is a filter term specified

This closes #8399
This commit is contained in:
Rob Fellows 2024-02-14 16:35:58 -05:00 committed by GitHub
parent 22de416ffc
commit 2792aa7038
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2171 additions and 6 deletions

View File

@ -77,6 +77,14 @@ const routes: Routes = [
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/queue/feature/queue.module').then((m) => m.QueueModule)
},
{
path: 'flow-configuration-history',
canMatch: [authenticationGuard],
loadChildren: () =>
import('./pages/flow-configuration-history/feature/flow-configuration-history.module').then(
(m) => m.FlowConfigurationHistoryModule
)
},
{
path: '',
canMatch: [authenticationGuard],

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 { RouterModule, Routes } from '@angular/router';
import { FlowConfigurationHistory } from './flow-configuration-history.component';
import { NgModule } from '@angular/core';
const routes: Routes = [
{
path: '',
component: FlowConfigurationHistory
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FlowConfigurationHistoryRoutingModule {}

View File

@ -0,0 +1,26 @@
<!--
~ 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="pb-5 flex flex-col h-screen justify-between gap-y-5">
<header class="nifi-header">
<navigation></navigation>
</header>
<div class="px-5 flex-1 flex flex-col">
<h3 class="text-xl bold counter-header">Flow Configuration History</h3>
<flow-configuration-history-listing class="flex-1"></flow-configuration-history-listing>
</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,53 @@
/*
* 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 { FlowConfigurationHistory } from './flow-configuration-history.component';
import { RouterModule } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';
import { provideMockStore } from '@ngrx/store/testing';
import { initialHistoryState } from '../state/flow-configuration-history-listing/flow-configuration-history-listing.reducer';
import { FlowConfigurationHistoryListing } from '../ui/flow-configuration-history-listing/flow-configuration-history-listing.component';
describe('FlowConfigurationHistory', () => {
let component: FlowConfigurationHistory;
let fixture: ComponentFixture<FlowConfigurationHistory>;
@Component({
selector: 'navigation',
standalone: true,
template: ''
})
class MockNavigation {}
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [FlowConfigurationHistory],
imports: [RouterModule, RouterTestingModule, MockNavigation, FlowConfigurationHistoryListing],
providers: [provideMockStore({ initialState: initialHistoryState })]
});
fixture = TestBed.createComponent(FlowConfigurationHistory);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,25 @@
/*
* 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';
@Component({
selector: 'flow-configuration-history',
templateUrl: './flow-configuration-history.component.html',
styleUrls: ['./flow-configuration-history.component.scss']
})
export class FlowConfigurationHistory {}

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 { FlowConfigurationHistory } from './flow-configuration-history.component';
import { Navigation } from '../../../ui/common/navigation/navigation.component';
import { FlowConfigurationHistoryRoutingModule } from './flow-configuration-history-routing.module';
import { FlowConfigurationHistoryListing } from '../ui/flow-configuration-history-listing/flow-configuration-history-listing.component';
import { StoreModule } from '@ngrx/store';
import { flowConfigurationHistoryFeatureKey, reducers } from '../state';
import { EffectsModule } from '@ngrx/effects';
import { FlowConfigurationHistoryListingEffects } from '../state/flow-configuration-history-listing/flow-configuration-history-listing.effects';
@NgModule({
imports: [
CommonModule,
Navigation,
FlowConfigurationHistoryRoutingModule,
FlowConfigurationHistoryListing,
StoreModule.forFeature(flowConfigurationHistoryFeatureKey, reducers),
EffectsModule.forFeature(FlowConfigurationHistoryListingEffects)
],
declarations: [FlowConfigurationHistory],
exports: [FlowConfigurationHistory]
})
export class FlowConfigurationHistoryModule {}

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 { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { HistoryQueryRequest, PurgeHistoryRequest } from '../state/flow-configuration-history-listing';
@Injectable({ providedIn: 'root' })
export class FlowConfigurationHistoryService {
private static readonly API: string = '../nifi-api';
constructor(private httpClient: HttpClient) {}
getHistory(request: HistoryQueryRequest): Observable<any> {
return this.httpClient.get(`${FlowConfigurationHistoryService.API}/flow/history`, { params: { ...request } });
}
purgeHistory(request: PurgeHistoryRequest): Observable<any> {
return this.httpClient.delete(`${FlowConfigurationHistoryService.API}/controller/history`, {
params: { ...request }
});
}
}

View File

@ -0,0 +1,60 @@
/*
* 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 {
ActionEntity,
HistoryEntity,
HistoryQueryRequest,
PurgeHistoryRequest,
SelectFlowConfigurationHistoryRequest
} from './index';
import { HttpErrorResponse } from '@angular/common/http';
const HISTORY_PREFIX = '[Flow Configuration History Listing]';
export const loadHistory = createAction(`${HISTORY_PREFIX} Load Listing`, props<{ request: HistoryQueryRequest }>());
export const loadHistorySuccess = createAction(
`${HISTORY_PREFIX} Load Listing Success`,
props<{ response: HistoryEntity }>()
);
export const resetHistoryState = createAction(`${HISTORY_PREFIX} Reset History State`);
export const clearHistorySelection = createAction(`${HISTORY_PREFIX} Clear Selection`);
export const selectHistoryItem = createAction(
`${HISTORY_PREFIX} Select History Item`,
props<{ request: SelectFlowConfigurationHistoryRequest }>()
);
export const flowConfigurationHistorySnackbarError = createAction(
`${HISTORY_PREFIX} Flow Configuration History Snackbar Error`,
props<{ errorResponse: HttpErrorResponse }>()
);
export const openMoreDetailsDialog = createAction(
`${HISTORY_PREFIX} Open More Details Dialog`,
props<{ request: ActionEntity }>()
);
export const openPurgeHistoryDialog = createAction(`${HISTORY_PREFIX} Open Purge History Dialog`);
export const purgeHistory = createAction(`${HISTORY_PREFIX} Purge History`, props<{ request: PurgeHistoryRequest }>());
export const purgeHistorySuccess = createAction(`${HISTORY_PREFIX} Purge History Success`);

View File

@ -0,0 +1,154 @@
/*
* 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, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { MatDialog } from '@angular/material/dialog';
import * as HistoryActions from './flow-configuration-history-listing.actions';
import { catchError, from, map, of, switchMap, take, tap } from 'rxjs';
import { selectHistoryQuery, selectHistoryStatus } from './flow-configuration-history-listing.selectors';
import { FlowConfigurationHistoryService } from '../../service/flow-configuration-history.service';
import { HistoryEntity } from './index';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { ActionDetails } from '../../ui/flow-configuration-history-listing/action-details/action-details.component';
import { PurgeHistory } from '../../ui/flow-configuration-history-listing/purge-history/purge-history.component';
import { YesNoDialog } from '../../../../ui/common/yes-no-dialog/yes-no-dialog.component';
import { isDefinedAndNotNull } from '../../../../state/shared';
import * as ErrorActions from '../../../../state/error/error.actions';
import { selectAbout } from '../../../../state/about/about.selectors';
@Injectable()
export class FlowConfigurationHistoryListingEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private errorHelper: ErrorHelper,
private dialog: MatDialog,
private historyService: FlowConfigurationHistoryService,
private router: Router
) {}
loadHistory$ = createEffect(() =>
this.actions$.pipe(
ofType(HistoryActions.loadHistory),
map((action) => action.request),
concatLatestFrom(() => this.store.select(selectHistoryStatus)),
switchMap(([request, status]) =>
from(this.historyService.getHistory(request)).pipe(
map((response: HistoryEntity) =>
HistoryActions.loadHistorySuccess({
response: response
})
),
catchError((errorResponse: HttpErrorResponse) =>
of(this.errorHelper.handleLoadingError(status, errorResponse))
)
)
)
)
);
flowConfigurationHistorySnackbarError = createEffect(() =>
this.actions$.pipe(
ofType(HistoryActions.flowConfigurationHistorySnackbarError),
map((action) => action.errorResponse),
switchMap((errorResponse) => of(ErrorActions.snackBarError({ error: errorResponse.error })))
)
);
openMoreDetailsDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(HistoryActions.openMoreDetailsDialog),
map((action) => action.request),
tap((actionEntity) => {
this.dialog.open(ActionDetails, {
data: actionEntity,
panelClass: 'medium-dialog'
});
})
),
{ dispatch: false }
);
openPurgeHistoryDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(HistoryActions.openPurgeHistoryDialog),
tap(() => {
const dialogReference = this.dialog.open(PurgeHistory, {
panelClass: 'medium-short-dialog'
});
dialogReference.componentInstance.submitPurgeRequest
.pipe(
isDefinedAndNotNull(),
concatLatestFrom(() => this.store.select(selectAbout).pipe(isDefinedAndNotNull())),
take(1)
)
.subscribe(([result, about]) => {
const yesNoRef = this.dialog.open(YesNoDialog, {
data: {
title: 'Confirm History Purge',
message: `Are you sure you want to delete all history before '${result.endDate} ${about.timezone}'?`
},
panelClass: 'small-dialog'
});
yesNoRef.componentInstance.yes.pipe(take(1)).subscribe(() => {
this.store.dispatch(HistoryActions.purgeHistory({ request: { ...result } }));
});
});
})
),
{
dispatch: false
}
);
purgeHistory$ = createEffect(() =>
this.actions$.pipe(
ofType(HistoryActions.purgeHistory),
map((action) => action.request),
switchMap((request) =>
from(this.historyService.purgeHistory(request)).pipe(
map(() => HistoryActions.purgeHistorySuccess()),
catchError((errorResponse: HttpErrorResponse) =>
of(HistoryActions.flowConfigurationHistorySnackbarError({ errorResponse }))
)
)
)
)
);
purgeHistorySuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(HistoryActions.purgeHistorySuccess),
concatLatestFrom(() => this.store.select(selectHistoryQuery)),
switchMap(([, query]) => {
if (query) {
return of(HistoryActions.loadHistory({ request: { ...query } }));
}
return of(HistoryActions.loadHistory({ request: { count: 50, offset: 0 } }));
})
)
);
}

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 { FlowConfigurationHistoryListingState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
clearHistorySelection,
flowConfigurationHistorySnackbarError,
loadHistory,
loadHistorySuccess,
purgeHistory,
purgeHistorySuccess,
resetHistoryState,
selectHistoryItem
} from './flow-configuration-history-listing.actions';
export const initialHistoryState: FlowConfigurationHistoryListingState = {
actions: [],
total: 0,
status: 'pending',
loadedTimestamp: '',
query: null,
selectedId: null,
purging: false
};
export const flowConfigurationHistoryListingReducer = createReducer(
initialHistoryState,
on(loadHistory, (state, { request }) => ({
...state,
status: 'loading' as const,
query: request
})),
on(loadHistorySuccess, (state, { response }) => ({
...state,
status: 'success' as const,
loadedTimestamp: response.history.lastRefreshed,
actions: response.history.actions,
total: response.history.total
})),
on(resetHistoryState, () => ({
...initialHistoryState
})),
on(purgeHistory, (state) => ({
...state,
query: {
...state.query,
count: 50,
offset: 0
},
purging: true
})),
on(purgeHistorySuccess, flowConfigurationHistorySnackbarError, (state) => ({
...state,
purging: false
})),
on(selectHistoryItem, (state, { request }) => ({
...state,
selectedId: request.id
})),
on(clearHistorySelection, (state) => ({
...state,
selectedId: null
}))
);

View File

@ -0,0 +1,55 @@
/*
* 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 { FlowConfigurationHistoryState, selectFlowConfigurationHistoryState } from '../index';
import { flowConfigurationHistoryListingFeatureKey, FlowConfigurationHistoryListingState } from './index';
export const selectFlowConfigurationHistoryListingState = createSelector(
selectFlowConfigurationHistoryState,
(state: FlowConfigurationHistoryState) => state[flowConfigurationHistoryListingFeatureKey]
);
export const selectHistoryActions = createSelector(
selectFlowConfigurationHistoryListingState,
(state: FlowConfigurationHistoryListingState) => state.actions
);
export const selectHistoryStatus = createSelector(
selectFlowConfigurationHistoryListingState,
(state: FlowConfigurationHistoryListingState) => state.status
);
export const selectHistoryLoadedTimestamp = createSelector(
selectFlowConfigurationHistoryListingState,
(state: FlowConfigurationHistoryListingState) => state.loadedTimestamp
);
export const selectHistoryQuery = createSelector(
selectFlowConfigurationHistoryListingState,
(state: FlowConfigurationHistoryListingState) => state.query
);
export const selectHistoryTotalResults = createSelector(
selectFlowConfigurationHistoryListingState,
(state: FlowConfigurationHistoryListingState) => state.total
);
export const selectedHistoryItem = createSelector(
selectFlowConfigurationHistoryListingState,
(state: FlowConfigurationHistoryListingState) => state.selectedId
);

View File

@ -0,0 +1,113 @@
/*
* 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 flowConfigurationHistoryListingFeatureKey = 'listing';
// Returned from API call to /nifi-api/flow/history
export interface HistoryEntity {
history: History;
}
export interface History {
total: number;
lastRefreshed: string;
actions: ActionEntity[];
}
export interface ActionEntity {
id: number;
timestamp: string;
sourceId: string;
canRead: boolean;
action: Action;
}
export interface Action {
id: number;
userIdentity: string;
timestamp: string;
sourceId: string;
sourceName: string;
sourceType: string;
componentDetails?: ExtensionDetails | RemoteProcessGroupDetails;
operation: string;
actionDetails?: ConfigureActionDetails | MoveActionDetails | ConnectionActionDetails | PurgeActionDetails;
}
export interface ExtensionDetails {
type: string;
}
export interface RemoteProcessGroupDetails {
uri: string;
}
export interface ConfigureActionDetails {
name: string;
previousValue: string;
value: string;
}
export interface MoveActionDetails {
previousGroupId: string;
previousGroup: string;
groupId: string;
group: string;
}
export interface ConnectionActionDetails {
sourceId: string;
sourceName: string;
sourceType: string;
relationship: string;
destinationId: string;
destinationName: string;
destinationType: string;
}
export interface PurgeActionDetails {
endDate: string;
}
export interface HistoryQueryRequest {
count: number;
offset: number;
sortColumn?: string;
sortOrder?: 'asc' | 'desc';
startDate?: string; // MM/dd/yyyy HH:mm:ss
endDate?: string; // MM/dd/yyyy HH:mm:ss
userIdentity?: string;
sourceId?: string;
}
export interface FlowConfigurationHistoryListingState {
actions: ActionEntity[];
total: number;
query: HistoryQueryRequest | null;
loadedTimestamp: string;
purging: boolean;
selectedId: number | null;
status: 'pending' | 'loading' | 'success';
}
export interface SelectFlowConfigurationHistoryRequest {
id: number;
}
export interface PurgeHistoryRequest {
endDate: string; // MM/dd/yyyy HH:mm:ss
}

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 {
flowConfigurationHistoryListingFeatureKey,
FlowConfigurationHistoryListingState
} from './flow-configuration-history-listing';
import { Action, combineReducers, createFeatureSelector } from '@ngrx/store';
import { flowConfigurationHistoryListingReducer } from './flow-configuration-history-listing/flow-configuration-history-listing.reducer';
export const flowConfigurationHistoryFeatureKey = 'flowConfigurationHistory';
export interface FlowConfigurationHistoryState {
[flowConfigurationHistoryListingFeatureKey]: FlowConfigurationHistoryListingState;
}
export function reducers(state: FlowConfigurationHistoryState | undefined, action: Action) {
return combineReducers({
[flowConfigurationHistoryListingFeatureKey]: flowConfigurationHistoryListingReducer
})(state, action);
}
export const selectFlowConfigurationHistoryState = createFeatureSelector<FlowConfigurationHistoryState>(
flowConfigurationHistoryFeatureKey
);

View File

@ -0,0 +1,213 @@
<!--
~ 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="action-details h-full">
<h2 mat-dialog-title>Action Details</h2>
<div class="action-details-content h-full">
<mat-dialog-content>
<div class="panel-content flex flex-col h-full w-full gap-y-4">
<div>
<div>Id</div>
<div class="value">{{ actionEntity.sourceId }}</div>
</div>
<ng-container *ngIf="actionEntity.action?.componentDetails">
<div *ngIf="isRemoteProcessGroup(actionEntity); else extension">
<div>Uri</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: getRemoteProcessGroupDetails(actionEntity)?.uri }
"></ng-container>
</div>
<ng-template #extension>
<div>
<div>Type</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: getExtensionDetails(actionEntity)?.type }
"></ng-container>
</div>
</ng-template>
</ng-container>
<ng-container *ngIf="actionEntity.action?.actionDetails">
<ng-container [ngSwitch]="actionEntity.action.operation">
<ng-container *ngSwitchCase="'Configure'">
<ng-container *ngIf="getConfigureActionDetails(actionEntity) as details">
<div>
<div>Name</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.name }
"></ng-container>
</div>
<div>
<div>Value</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.value }
"></ng-container>
</div>
<div>
<div>Previous Value</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.previousValue }
"></ng-container>
</div>
</ng-container>
</ng-container>
<ng-container *ngSwitchCase="'Connect' || 'Disconnect'">
<ng-container *ngIf="getConnectActionDetails(actionEntity) as details">
<div>
<div>Source Id</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.sourceId }
"></ng-container>
</div>
<div>
<div>Source Name</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.sourceName }
"></ng-container>
</div>
<div>
<div>Source Type</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.sourceType }
"></ng-container>
</div>
<div>
<div>Relationship(s)</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.relationship }
"></ng-container>
</div>
<div>
<div>Destination Id</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.destinationId }
"></ng-container>
</div>
<div>
<div>Destination Name</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.destinationName }
"></ng-container>
</div>
<div>
<div>Destination Type</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.destinationType }
"></ng-container>
</div>
</ng-container>
</ng-container>
<ng-container *ngSwitchCase="'Move'">
<ng-container *ngIf="getMoveActionDetails(actionEntity) as details">
<div>
<div>Group</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.group }
"></ng-container>
</div>
<div>
<div>Group Id</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.groupId }
"></ng-container>
</div>
<div>
<div>Previous Group</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.previousGroup }
"></ng-container>
</div>
<div>
<div>Previous Group Id</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.previousGroupId }
"></ng-container>
</div>
</ng-container>
</ng-container>
<ng-container *ngSwitchCase="'Purge'">
<ng-container *ngIf="getPurgeActionDetails(actionEntity) as details">
<div>
<div>End Date</div>
<ng-container
*ngTemplateOutlet="
formatValue;
context: { $implicit: details.endDate }
"></ng-container>
</div>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</div>
<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>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button color="primary" mat-raised-button mat-dialog-close>Ok</button>
</mat-dialog-actions>
</div>
</div>

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.
*/
.action-details {
.action-details-content {
.mdc-dialog__content {
padding: 0 16px;
font-size: 14px;
.panel-content {
position: relative;
height: 350px;
overflow-y: auto;
}
}
}
}

View File

@ -0,0 +1,56 @@
/*
* 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 { ActionDetails } from './action-details.component';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ActionEntity } from '../../../state/flow-configuration-history-listing';
describe('ActionDetails', () => {
let component: ActionDetails;
let fixture: ComponentFixture<ActionDetails>;
const data: ActionEntity = {
id: 276,
timestamp: '02/12/2024 12:52:54 EST',
sourceId: '9e721628-018d-1000-38cc-5ea304d451c7',
canRead: true,
action: {
id: 276,
userIdentity: 'test',
timestamp: '02/12/2024 12:52:54 EST',
sourceId: '9e721628-018d-1000-38cc-5ea304d451c7',
sourceName: 'dummy',
sourceType: 'ProcessGroup',
operation: 'Remove'
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ActionDetails],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(ActionDetails);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,92 @@
/*
* 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, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import {
ActionEntity,
ConfigureActionDetails,
ConnectionActionDetails,
ExtensionDetails,
MoveActionDetails,
PurgeActionDetails,
RemoteProcessGroupDetails
} from '../../../state/flow-configuration-history-listing';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { PipesModule } from '../../../../../pipes/pipes.module';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'action-details',
standalone: true,
imports: [CommonModule, MatDialogModule, PipesModule, MatButtonModule],
templateUrl: './action-details.component.html',
styleUrls: ['./action-details.component.scss']
})
export class ActionDetails {
constructor(
@Inject(MAT_DIALOG_DATA) public actionEntity: ActionEntity,
private nifiCommon: NiFiCommon
) {}
isRemoteProcessGroup(actionEntity: ActionEntity): boolean {
return actionEntity.action.sourceType === 'RemoteProcessGroup';
}
getRemoteProcessGroupDetails(actionEntity: ActionEntity): RemoteProcessGroupDetails | null {
if (!this.isRemoteProcessGroup(actionEntity)) {
return null;
}
return actionEntity.action.componentDetails as RemoteProcessGroupDetails;
}
getExtensionDetails(actionEntity: ActionEntity): ExtensionDetails | null {
if (this.isRemoteProcessGroup(actionEntity)) {
return null;
}
return actionEntity.action.componentDetails as ExtensionDetails;
}
getConfigureActionDetails(actionEntity: ActionEntity): ConfigureActionDetails | null {
if (actionEntity.action.operation !== 'Configure') {
return null;
}
return actionEntity.action.actionDetails as ConfigureActionDetails;
}
getConnectActionDetails(actionEntity: ActionEntity): ConnectionActionDetails | null {
if (!['Connect', 'Disconnect'].includes(actionEntity.action.operation)) {
return null;
}
return actionEntity.action.actionDetails as ConnectionActionDetails;
}
getMoveActionDetails(actionEntity: ActionEntity): MoveActionDetails | null {
if (actionEntity.action.operation !== 'Move') {
return null;
}
return actionEntity.action.actionDetails as MoveActionDetails;
}
getPurgeActionDetails(actionEntity: ActionEntity): PurgeActionDetails | null {
if (actionEntity.action.operation !== 'Purge') {
return null;
}
return actionEntity.action.actionDetails as PurgeActionDetails;
}
}

View File

@ -0,0 +1,131 @@
<!--
~ 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.
-->
<ng-container *ngIf="historyListingState$ | async; let state">
<div *ngIf="isInitialLoading(state); else loaded">
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
<ng-template #loaded>
<div class="flow-configuration-history-listing flex flex-col h-full gap-y-2">
<div class="flex align-middle justify-between">
<div class="flow-configuration-filter flex-1">
<form [formGroup]="filterForm">
<div class="flex pt-2 gap-1 items-baseline">
<div>
<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">
<ng-container *ngFor="let option of filterableColumns">
<mat-option [value]="option.key"> {{ option.label }} </mat-option>
</ng-container>
</mat-select>
</mat-form-field>
</div>
<div class="flex ml-6 gap-x-2">
<mat-form-field>
<mat-label>Date Range</mat-label>
<mat-date-range-input [rangePicker]="picker">
<input
matStartDate
formControlName="filterStartDate"
placeholder="Start date"
title="The start date in the format 'mm/dd/yyyy'. Must also specify start time." />
<input
matEndDate
formControlName="filterEndDate"
placeholder="End date"
title="The end date in the format 'mm/dd/yyyy'. Must also specify end time." />
</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>
<mat-form-field>
<mat-label>Start Time ({{ (about$ | async)?.timezone }})</mat-label>
<input
matInput
type="time"
step="1"
formControlName="filterStartTime"
placeholder="hh:mm:ss"
title="The start time in the format 'hh:mm:ss'. Must also specify start date." />
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>End Time ({{ (about$ | async)?.timezone }})</mat-label>
<input
matInput
type="time"
step="1"
formControlName="filterEndTime"
placeholder="hh:mm:ss"
title="The end time in the format 'hh:mm:ss'. Must also specify end date." />
</mat-form-field>
</div>
</div>
<div class="flex ml-4">
<a (click)="resetFilter($event)">Clear Filter</a>
</div>
</div>
</form>
</div>
<div class="mt-4" *ngIf="(currentUser$ | async)?.controllerPermissions?.canWrite">
<button class="nifi-button" (click)="purgeHistoryClicked()">
<i class="fa fa-eraser"></i>
</button>
</div>
</div>
<div class="flex-1">
<flow-configuration-history-table
[historyActions]="state.actions"
[selectedHistoryActionId]="selectedHistoryId$ | async"
(moreDetailsClicked)="openMoreDetails($event)"
(selectionChanged)="selectionChanged($event)"
(sortChanged)="sortChanged($event)"></flow-configuration-history-table>
</div>
<div class="flex justify-between align-middle">
<div class="refresh-container flex items-center gap-x-2">
<button class="nifi-button" (click)="refresh()">
<i class="fa fa-refresh" [class.fa-spin]="state.status === 'loading'"></i>
</button>
<div>Last updated:</div>
<div class="refresh-timestamp">{{ state.loadedTimestamp }}</div>
</div>
<div>
<mat-paginator
[length]="state.total"
[pageSize]="pageSize"
[pageIndex]="getPageIndex()"
[hidePageSize]="true"
[showFirstLastButtons]="true"
(page)="paginationChanged($event)"></mat-paginator>
</div>
</div>
</div>
</ng-template>
</ng-container>

View File

@ -0,0 +1,22 @@
/*!
* 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.
*/
.flow-configuration-history-listing {
.mdc-text-field__input::-webkit-calendar-picker-indicator {
display: block;
}
}

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 { FlowConfigurationHistoryListing } from './flow-configuration-history-listing.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialHistoryState } from '../../state/flow-configuration-history-listing/flow-configuration-history-listing.reducer';
describe('FlowConfigurationHistoryListing', () => {
let component: FlowConfigurationHistoryListing;
let fixture: ComponentFixture<FlowConfigurationHistoryListing>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FlowConfigurationHistoryListing],
providers: [provideMockStore({ initialState: initialHistoryState })]
});
fixture = TestBed.createComponent(FlowConfigurationHistoryListing);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,317 @@
/*
* 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, DestroyRef, inject, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import * as HistoryActions from '../../state/flow-configuration-history-listing/flow-configuration-history-listing.actions';
import { loadHistory } from '../../state/flow-configuration-history-listing/flow-configuration-history-listing.actions';
import {
ActionEntity,
FlowConfigurationHistoryListingState,
HistoryQueryRequest
} from '../../state/flow-configuration-history-listing';
import { Store } from '@ngrx/store';
import {
selectedHistoryItem,
selectFlowConfigurationHistoryListingState,
selectHistoryQuery
} from '../../state/flow-configuration-history-listing/flow-configuration-history-listing.selectors';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { initialHistoryState } from '../../state/flow-configuration-history-listing/flow-configuration-history-listing.reducer';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { FlowConfigurationHistoryTable } from './flow-configuration-history-table/flow-configuration-history-table.component';
import { Sort } from '@angular/material/sort';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isDefinedAndNotNull } from '../../../../state/shared';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
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 { MatDatepickerModule } from '@angular/material/datepicker';
import { selectAbout } from '../../../../state/about/about.selectors';
import { loadAbout } from '../../../../state/about/about.actions';
import { debounceTime } from 'rxjs';
import { NiFiCommon } from '../../../../service/nifi-common.service';
import { MatButtonModule } from '@angular/material/button';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
interface FilterableColumn {
key: string;
label: string;
}
@Component({
selector: 'flow-configuration-history-listing',
standalone: true,
imports: [
CommonModule,
NgxSkeletonLoaderModule,
MatPaginatorModule,
FlowConfigurationHistoryTable,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
MatDatepickerModule,
MatButtonModule
],
templateUrl: './flow-configuration-history-listing.component.html',
styleUrls: ['./flow-configuration-history-listing.component.scss']
})
export class FlowConfigurationHistoryListing implements OnInit, OnDestroy {
private static readonly DEFAULT_START_TIME: string = '00:00:00';
private static readonly DEFAULT_END_TIME: string = '23:59:59';
private static readonly TIME_REGEX = /^([0-1]\d|2[0-3]):([0-5]\d):([0-5]\d)$/;
private destroyRef = inject(DestroyRef);
historyListingState$ = this.store.select(selectFlowConfigurationHistoryListingState);
selectedHistoryId$ = this.store.select(selectedHistoryItem);
queryRequest$ = this.store.select(selectHistoryQuery);
about$ = this.store.select(selectAbout);
currentUser$ = this.store.select(selectCurrentUser);
pageSize = 50;
queryRequest: HistoryQueryRequest = {
count: this.pageSize,
offset: 0,
startDate: this.getFormattedStartDateTime(),
endDate: this.getFormattedEndDateTime()
};
filterForm: FormGroup;
filterableColumns: FilterableColumn[] = [
{ key: 'sourceId', label: 'id' },
{ key: 'userIdentity', label: 'user' }
];
constructor(
private store: Store<FlowConfigurationHistoryListingState>,
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon
) {
this.queryRequest$
.pipe(takeUntilDestroyed(), isDefinedAndNotNull())
.subscribe((queryRequest) => (this.queryRequest = queryRequest));
const now: Date = new Date();
const twoWeeksAgo: Date = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 14);
this.filterForm = this.formBuilder.group({
filterTerm: '',
filterColumn: this.filterableColumns[0].key,
filterStartDate: new FormControl(twoWeeksAgo),
filterStartTime: new FormControl(FlowConfigurationHistoryListing.DEFAULT_START_TIME, [
Validators.required,
Validators.pattern(FlowConfigurationHistoryListing.TIME_REGEX)
]),
filterEndDate: new FormControl(now),
filterEndTime: new FormControl(FlowConfigurationHistoryListing.DEFAULT_END_TIME, [
Validators.required,
Validators.pattern(FlowConfigurationHistoryListing.TIME_REGEX)
])
});
}
ngOnDestroy(): void {
this.store.dispatch(HistoryActions.resetHistoryState());
}
ngOnInit(): void {
this.refresh();
this.store.dispatch(loadAbout());
this.onFormChanges();
}
isInitialLoading(state: FlowConfigurationHistoryListingState): boolean {
return state.loadedTimestamp == initialHistoryState.loadedTimestamp;
}
refresh() {
this.store.dispatch(HistoryActions.loadHistory({ request: this.queryRequest }));
}
paginationChanged(pageEvent: PageEvent): void {
// Initiate the call to the backend for the requested page of data
this.store.dispatch(
HistoryActions.loadHistory({
request: {
...this.queryRequest,
count: this.pageSize,
offset: pageEvent.pageIndex * this.pageSize
}
})
);
// clear out any selection
this.store.dispatch(HistoryActions.clearHistorySelection());
}
selectionChanged(historyItem: ActionEntity) {
if (historyItem) {
this.store.dispatch(HistoryActions.selectHistoryItem({ request: { id: historyItem.id } }));
}
}
sortChanged(sort: Sort) {
if (this.queryRequest?.sortOrder !== sort.active || this.queryRequest.sortColumn !== sort.direction) {
// always reset the pagination when changing sort
this.store.dispatch(
HistoryActions.loadHistory({
request: {
...this.queryRequest,
count: this.pageSize,
offset: 0,
sortColumn: sort.active,
sortOrder: sort.direction ? `${sort.direction}` : 'asc'
}
})
);
// clear out any selection
this.store.dispatch(HistoryActions.clearHistorySelection());
}
}
getPageIndex(): number {
if (this.queryRequest.offset >= 0) {
return this.queryRequest.offset / this.pageSize;
} else return 0;
}
private onFormChanges() {
this.filterForm.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(300))
.subscribe((formValue) => {
if (!this.filterForm.valid) {
return;
}
// clear out any selection
this.store.dispatch(HistoryActions.clearHistorySelection());
// always reset the pagination state when a filter changes
const historyRequest: HistoryQueryRequest = {
...this.queryRequest,
offset: 0
};
if (formValue.filterTerm) {
if (formValue.filterColumn === 'sourceId') {
historyRequest.sourceId = formValue.filterTerm;
delete historyRequest.userIdentity;
} else {
historyRequest.userIdentity = formValue.filterTerm;
delete historyRequest.sourceId;
}
} else {
delete historyRequest.sourceId;
delete historyRequest.userIdentity;
}
let start: Date = new Date();
if (formValue.filterStartDate) {
historyRequest.startDate = this.getFormattedStartDateTime(
formValue.filterStartDate,
formValue.filterStartTime
);
start = new Date(historyRequest.startDate);
}
let end: Date = new Date(0);
if (formValue.filterEndDate) {
historyRequest.endDate = this.getFormattedEndDateTime(
formValue.filterEndDate,
formValue.filterEndTime
);
end = new Date(historyRequest.endDate);
}
if (this.queryChanged(historyRequest) && start.getTime() <= end.getTime()) {
this.store.dispatch(
loadHistory({
request: historyRequest
})
);
}
});
}
private queryChanged(historyRequest: HistoryQueryRequest) {
const before: HistoryQueryRequest = this.queryRequest;
const proposed: HistoryQueryRequest = historyRequest;
return (
proposed.endDate !== before.endDate ||
proposed.startDate !== before.startDate ||
proposed.sourceId != before.sourceId ||
proposed.userIdentity != before.userIdentity
);
}
resetFilter(event: MouseEvent) {
event.stopPropagation();
const now = new Date();
const twoWeeksAgo: Date = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 14);
this.filterForm.reset({
filterTerm: '',
filterColumn: 'sourceId',
filterStartTime: FlowConfigurationHistoryListing.DEFAULT_START_TIME,
filterStartDate: twoWeeksAgo,
filterEndTime: FlowConfigurationHistoryListing.DEFAULT_END_TIME,
filterEndDate: now
});
}
openMoreDetails(actionEntity: ActionEntity) {
this.store.dispatch(
HistoryActions.openMoreDetailsDialog({
request: actionEntity
})
);
}
purgeHistoryClicked() {
this.store.dispatch(HistoryActions.openPurgeHistoryDialog());
}
private getFormatDateTime(date: Date, time: string): string {
let formatted = this.nifiCommon.formatDateTime(date);
// 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];
// combine the pieces into the format the api requires
formatted = `${formattedStartDate} ${time}`;
}
return formatted;
}
private getFormattedStartDateTime(date?: Date, time?: string): string {
const now = new Date();
const twoWeeksAgo: Date = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 14);
const d: Date = date ? date : twoWeeksAgo;
const t: string = time ? time : FlowConfigurationHistoryListing.DEFAULT_START_TIME;
return this.getFormatDateTime(d, t);
}
private getFormattedEndDateTime(date?: Date, time?: string): string {
const d: Date = date ? date : new Date();
const t: string = time ? time : FlowConfigurationHistoryListing.DEFAULT_END_TIME;
return this.getFormatDateTime(d, t);
}
}

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.
-->
<div class="flow-configuration-history-table flex-1 relative h-full w-full">
<div class="listing-table overflow-y-auto border absolute inset-0">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortDisableClear
(matSortChange)="sortData($event)"
[matSortActive]="initialSortColumn"
[matSortDirection]="initialSortDirection">
<ng-container matColumnDef="moreDetails">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<ng-container>
<div class="flex items-center gap-x-3">
<div
*ngIf="canRead(item)"
class="pointer fa fa-info-circle"
title="View Details"
(click)="moreDetails(item)"></div>
</div>
</ng-container>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="timestamp">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
<div class="flex-1 overflow-ellipsis overflow-hidden whitespace-nowrap">Date/Time</div>
</th>
<td mat-cell *matCellDef="let item" [title]="formatTimestamp(item)">
{{ formatTimestamp(item) }}
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="sourceName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
<div class="flex-1 overflow-ellipsis overflow-hidden whitespace-nowrap">Name</div>
</th>
<td mat-cell *matCellDef="let item" [title]="formatName(item)">
<span [class.blank]="!item.action?.sourceName">{{ formatName(item) }}</span>
</td>
</ng-container>
<ng-container matColumnDef="sourceType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
<div class="flex-1 overflow-ellipsis overflow-hidden whitespace-nowrap">Type</div>
</th>
<td mat-cell *matCellDef="let item" [title]="formatType(item)">
{{ formatType(item) }}
</td>
</ng-container>
<ng-container matColumnDef="operation">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
<div class="flex-1 overflow-ellipsis overflow-hidden whitespace-nowrap">Operation</div>
</th>
<td mat-cell *matCellDef="let item" [title]="formatOperation(item)">
{{ formatOperation(item) }}
</td>
</ng-container>
<ng-container matColumnDef="userIdentity">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
<div class="flex-1 overflow-ellipsis overflow-hidden whitespace-nowrap">User</div>
</th>
<td mat-cell *matCellDef="let item" [title]="formatUser(item)">
{{ formatUser(item) }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
[class.even]="even"
(click)="select(row)"
[class.unset]="!canRead(row)"
[class.selected]="isSelected(row)"></tr>
</table>
</div>
</div>

View File

@ -0,0 +1,25 @@
/*!
* 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.
*/
.flow-configuration-history-table {
.listing-table {
.mat-column-moreDetails {
width: 32px;
min-width: 32px;
}
}
}

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 { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlowConfigurationHistoryTable } from './flow-configuration-history-table.component';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('FlowConfigurationHistoryTable', () => {
let component: FlowConfigurationHistoryTable;
let fixture: ComponentFixture<FlowConfigurationHistoryTable>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FlowConfigurationHistoryTable, NoopAnimationsModule]
});
fixture = TestBed.createComponent(FlowConfigurationHistoryTable);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,110 @@
/*
* 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, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatSortModule, Sort } from '@angular/material/sort';
import { ActionEntity } from '../../../state/flow-configuration-history-listing';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
@Component({
selector: 'flow-configuration-history-table',
standalone: true,
imports: [CommonModule, MatTableModule, MatSortModule],
templateUrl: './flow-configuration-history-table.component.html',
styleUrls: ['./flow-configuration-history-table.component.scss']
})
export class FlowConfigurationHistoryTable {
@Input() selectedHistoryActionId: number | null = null;
@Input() initialSortColumn: 'timestamp' | 'sourceName' | 'sourceType' | 'operation' | 'userIdentity' = 'timestamp';
@Input() initialSortDirection: 'asc' | 'desc' = 'desc';
@Input() set historyActions(historyActions: ActionEntity[]) {
if (historyActions) {
this.dataSource.data = historyActions;
}
}
@Output() selectionChanged: EventEmitter<ActionEntity> = new EventEmitter<ActionEntity>();
@Output() sortChanged: EventEmitter<Sort> = new EventEmitter<Sort>();
@Output() moreDetailsClicked: EventEmitter<ActionEntity> = new EventEmitter<ActionEntity>();
activeSort: Sort = {
active: this.initialSortColumn,
direction: this.initialSortDirection
};
displayedColumns: string[] = ['moreDetails', 'timestamp', 'sourceName', 'sourceType', 'operation', 'userIdentity'];
dataSource: MatTableDataSource<ActionEntity> = new MatTableDataSource<ActionEntity>();
constructor(private nifiCommon: NiFiCommon) {}
sortData(sort: Sort) {
this.sortChanged.next(sort);
}
canRead(item: ActionEntity): boolean {
return item.canRead;
}
private format(item: ActionEntity, property: string): string {
if (this.canRead(item) && Object.hasOwn(item.action, property)) {
const value = (item.action as any)[property];
if (!value) {
return 'Empty String Set';
}
return value;
}
return 'Not Authorized';
}
formatTimestamp(item: ActionEntity): string {
return this.format(item, 'timestamp');
}
formatName(item: ActionEntity): string {
return this.format(item, 'sourceName');
}
formatType(item: ActionEntity): string {
return this.format(item, 'sourceType');
}
formatOperation(item: ActionEntity): string {
return this.format(item, 'operation');
}
formatUser(item: ActionEntity): string {
return this.format(item, 'userIdentity');
}
select(item: ActionEntity) {
this.selectionChanged.next(item);
}
isSelected(item: ActionEntity): boolean {
if (this.selectedHistoryActionId) {
return item.id === this.selectedHistoryActionId;
}
return false;
}
moreDetails(item: ActionEntity) {
this.moreDetailsClicked.next(item);
}
}

View File

@ -0,0 +1,65 @@
<!--
~ 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="purge-history">
<h2 mat-dialog-title>Purge History</h2>
<form class="purge-history-form" [formGroup]="purgeHistoryForm">
<div class="purge-history-content h-full">
<mat-dialog-content class="h-full">
<div class="panel-content flex h-full w-full gap-y-4 gap-x-4 pt-4">
<div>
<mat-form-field>
<mat-label>Before Date</mat-label>
<input
matInput
[matDatepicker]="endDatePicker"
formControlName="endDate"
placeholder="mm/dd/yyyy"
title="The end date in the format 'mm/dd/yyyy'. Must also specify end time." />
<mat-datepicker-toggle matIconSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Before Time ({{ (about$ | async)?.timezone }})</mat-label>
<input
matInput
type="time"
step="1"
formControlName="endTime"
placeholder="hh:mm:ss"
title="The start time in the format 'hh:mm:ss'. Must also specify start date." />
</mat-form-field>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button color="primary" mat-stroked-button mat-dialog-close>Cancel</button>
<button
color="primary"
(click)="submit()"
[disabled]="!purgeHistoryForm.valid"
mat-raised-button
mat-dialog-close>
Ok
</button>
</mat-dialog-actions>
</div>
</form>
</div>

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.
*/
.purge-history {
.purge-history-content {
.mdc-dialog__content {
padding: 0 16px;
font-size: 14px;
.panel-content {
height: 150px;
}
}
.mdc-text-field__input::-webkit-calendar-picker-indicator {
display: block;
}
}
}

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.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PurgeHistory } from './purge-history.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialHistoryState } from '../../../state/flow-configuration-history-listing/flow-configuration-history-listing.reducer';
import { MatNativeDateModule } from '@angular/material/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('PurgeHistory', () => {
let component: PurgeHistory;
let fixture: ComponentFixture<PurgeHistory>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [PurgeHistory, MatNativeDateModule, NoopAnimationsModule],
providers: [provideMockStore({ initialState: initialHistoryState })]
});
fixture = TestBed.createComponent(PurgeHistory);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,93 @@
/*
* 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, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import {
FlowConfigurationHistoryListingState,
PurgeHistoryRequest
} from '../../../state/flow-configuration-history-listing';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { MatInputModule } from '@angular/material/input';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { selectAbout } from '../../../../../state/about/about.selectors';
import { Store } from '@ngrx/store';
@Component({
selector: 'purge-history',
standalone: true,
imports: [CommonModule, MatDialogModule, MatButtonModule, ReactiveFormsModule, MatInputModule, MatDatepickerModule],
templateUrl: './purge-history.component.html',
styleUrls: ['./purge-history.component.scss']
})
export class PurgeHistory {
private static readonly DEFAULT_PURGE_TIME: string = '00:00:00';
private static readonly TIME_REGEX = /^([0-1]\d|2[0-3]):([0-5]\d):([0-5]\d)$/;
purgeHistoryForm: FormGroup;
about$ = this.store.select(selectAbout);
@Output() submitPurgeRequest: EventEmitter<PurgeHistoryRequest> = new EventEmitter<PurgeHistoryRequest>();
constructor(
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon,
private store: Store<FlowConfigurationHistoryListingState>
) {
const now: Date = new Date();
const aMonthAgo: Date = new Date();
aMonthAgo.setMonth(now.getMonth() - 1);
this.purgeHistoryForm = this.formBuilder.group({
endDate: new FormControl(aMonthAgo, Validators.required),
endTime: new FormControl(PurgeHistory.DEFAULT_PURGE_TIME, [
Validators.required,
Validators.pattern(PurgeHistory.TIME_REGEX)
])
});
}
submit() {
const formEndDate = this.purgeHistoryForm.get('endDate')?.value;
const formEndTime = this.purgeHistoryForm.get('endTime')?.value;
const request: PurgeHistoryRequest = {
endDate: formEndDate
};
if (formEndDate && formEndTime) {
const formatted = this.nifiCommon.formatDateTime(formEndDate);
// 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 = formEndTime;
if (!endTime) {
endTime = PurgeHistory.DEFAULT_PURGE_TIME;
}
// combine the pieces into the format the api requires
request.endDate = `${formattedEndDate} ${endTime}`;
}
this.submitPurgeRequest.next(request);
}
}
}

View File

@ -47,7 +47,7 @@
<div class="w-full flex flex-col">
<mat-form-field>
<mat-label>Start Time ({{ timezone }})</mat-label>
<input matInput type="text" formControlName="startTime" />
<input matInput type="time" step="1" formControlName="startTime" />
</mat-form-field>
<mat-form-field>
<mat-label>Minimum File Size</mat-label>
@ -57,7 +57,7 @@
<div class="w-full flex flex-col">
<mat-form-field>
<mat-label>End Time ({{ timezone }})</mat-label>
<input matInput type="text" formControlName="endTime" />
<input matInput type="time" step="1" formControlName="endTime" />
</mat-form-field>
<mat-form-field>
<mat-label>Maximum File Size</mat-label>

View File

@ -17,7 +17,7 @@
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 { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
@ -54,6 +54,7 @@ export class ProvenanceSearchDialog {
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';
private static readonly TIME_REGEX = /^([0-1]\d|2[0-3]):([0-5]\d):([0-5]\d)$/;
provenanceOptionsForm: FormGroup;
@ -106,9 +107,15 @@ export class ProvenanceSearchDialog {
this.provenanceOptionsForm = this.formBuilder.group({
startDate: new FormControl(startDate),
startTime: new FormControl(startTime),
startTime: new FormControl(startTime, [
Validators.required,
Validators.pattern(ProvenanceSearchDialog.TIME_REGEX)
]),
endDate: new FormControl(endDate),
endTime: new FormControl(endTime),
endTime: new FormControl(endTime, [
Validators.required,
Validators.pattern(ProvenanceSearchDialog.TIME_REGEX)
]),
minFileSize: new FormControl(minFileSize),
maxFileSize: new FormControl(maxFileSize)
});

View File

@ -89,7 +89,7 @@
<i class="fa fa-fw fa-cubes mr-2"></i>
Cluster
</button>
<button mat-menu-item class="global-menu-item">
<button mat-menu-item class="global-menu-item" [routerLink]="['/flow-configuration-history']">
<i class="fa fa-fw fa-history mr-2"></i>
Flow Configuration History
</button>