NIFI-12742: Error Handling in Summary, Users, and Queue Listing (#8366)

- Error handling in Users.
- Error handling in Summary.
- Error handling in Queue Listing.
- Addressing review feedback.
- Dispatching delete success to ensure the active request is reset upon completion.

This closes #8366
This commit is contained in:
Matt Gilman 2024-02-09 09:37:07 -05:00 committed by GitHub
parent 4094c7f599
commit 439c59e733
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 244 additions and 131 deletions

View File

@ -101,9 +101,9 @@ export interface FlowFileDialogRequest {
} }
export interface QueueListingState { export interface QueueListingState {
requestEntity: ListingRequestEntity | null; activeListingRequest: ListingRequest | null;
completedListingRequest: ListingRequest | null;
connectionLabel: string; connectionLabel: string;
loadedTimestamp: string; loadedTimestamp: string;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success'; status: 'pending' | 'loading' | 'error' | 'success';
} }

View File

@ -68,6 +68,8 @@ export const stopPollingQueueListingRequest = createAction(`${QUEUE_PREFIX} Stop
export const deleteQueueListingRequest = createAction(`${QUEUE_PREFIX} Delete Queue Listing Request`); export const deleteQueueListingRequest = createAction(`${QUEUE_PREFIX} Delete Queue Listing Request`);
export const deleteQueueListingRequestSuccess = createAction(`${QUEUE_PREFIX} Delete Queue Listing Request Success`);
export const viewFlowFile = createAction(`${QUEUE_PREFIX} View FlowFile`, props<{ request: ViewFlowFileRequest }>()); export const viewFlowFile = createAction(`${QUEUE_PREFIX} View FlowFile`, props<{ request: ViewFlowFileRequest }>());
export const openFlowFileDialog = createAction( export const openFlowFileDialog = createAction(

View File

@ -21,7 +21,7 @@ import * as QueueListingActions from './queue-listing.actions';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { CanvasState } from '../../../flow-designer/state'; import { CanvasState } from '../../../flow-designer/state';
import { asyncScheduler, catchError, filter, from, interval, map, of, switchMap, take, takeUntil, tap } from 'rxjs'; import { asyncScheduler, catchError, filter, from, interval, map, of, switchMap, take, takeUntil, tap } from 'rxjs';
import { selectConnectionIdFromRoute, selectListingRequestEntity } from './queue-listing.selectors'; import { selectConnectionIdFromRoute, selectActiveListingRequest } from './queue-listing.selectors';
import { QueueService } from '../../service/queue.service'; import { QueueService } from '../../service/queue.service';
import { ListingRequest } from './index'; import { ListingRequest } from './index';
import { CancelDialog } from '../../../../ui/common/cancel-dialog/cancel-dialog.component'; import { CancelDialog } from '../../../../ui/common/cancel-dialog/cancel-dialog.component';
@ -30,6 +30,10 @@ import { selectAbout } from '../../../../state/about/about.selectors';
import { FlowFileDialog } from '../../ui/queue-listing/flowfile-dialog/flowfile-dialog.component'; import { FlowFileDialog } from '../../ui/queue-listing/flowfile-dialog/flowfile-dialog.component';
import { NiFiCommon } from '../../../../service/nifi-common.service'; import { NiFiCommon } from '../../../../service/nifi-common.service';
import { isDefinedAndNotNull } from '../../../../state/shared'; import { isDefinedAndNotNull } from '../../../../state/shared';
import { HttpErrorResponse } from '@angular/common/http';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { stopPollingQueueListingRequest } from './queue-listing.actions';
@Injectable() @Injectable()
export class QueueListingEffects { export class QueueListingEffects {
@ -37,6 +41,7 @@ export class QueueListingEffects {
private actions$: Actions, private actions$: Actions,
private store: Store<CanvasState>, private store: Store<CanvasState>,
private queueService: QueueService, private queueService: QueueService,
private errorHelper: ErrorHelper,
private dialog: MatDialog, private dialog: MatDialog,
private nifiCommon: NiFiCommon private nifiCommon: NiFiCommon
) {} ) {}
@ -103,13 +108,19 @@ export class QueueListingEffects {
} }
}) })
), ),
catchError((error) => catchError((errorResponse: HttpErrorResponse) => {
of( if (this.errorHelper.showErrorInContext(errorResponse.status)) {
QueueListingActions.queueListingApiError({ return of(
error: error.error QueueListingActions.queueListingApiError({
}) error: errorResponse.error
) })
) );
} else {
this.store.dispatch(stopPollingQueueListingRequest());
return of(this.errorHelper.fullScreenError(errorResponse));
}
})
); );
}) })
) )
@ -155,9 +166,9 @@ export class QueueListingEffects {
pollQueueListingRequest$ = createEffect(() => pollQueueListingRequest$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(QueueListingActions.pollQueueListingRequest), ofType(QueueListingActions.pollQueueListingRequest),
concatLatestFrom(() => this.store.select(selectListingRequestEntity).pipe(isDefinedAndNotNull())), concatLatestFrom(() => this.store.select(selectActiveListingRequest).pipe(isDefinedAndNotNull())),
switchMap(([, requestEntity]) => { switchMap(([, listingRequest]) => {
return from(this.queueService.pollQueueListingRequest(requestEntity.listingRequest)).pipe( return from(this.queueService.pollQueueListingRequest(listingRequest)).pipe(
map((response) => map((response) =>
QueueListingActions.pollQueueListingRequestSuccess({ QueueListingActions.pollQueueListingRequestSuccess({
response: { response: {
@ -165,13 +176,19 @@ export class QueueListingEffects {
} }
}) })
), ),
catchError((error) => catchError((errorResponse: HttpErrorResponse) => {
of( if (this.errorHelper.showErrorInContext(errorResponse.status)) {
QueueListingActions.queueListingApiError({ return of(
error: error.error QueueListingActions.queueListingApiError({
}) error: errorResponse.error
) })
) );
} else {
this.store.dispatch(stopPollingQueueListingRequest());
return of(this.errorHelper.fullScreenError(errorResponse));
}
})
); );
}) })
) )
@ -193,20 +210,23 @@ export class QueueListingEffects {
) )
); );
deleteQueueListingRequest$ = createEffect( deleteQueueListingRequest$ = createEffect(() =>
() => this.actions$.pipe(
this.actions$.pipe( ofType(QueueListingActions.deleteQueueListingRequest),
ofType(QueueListingActions.deleteQueueListingRequest), concatLatestFrom(() => this.store.select(selectActiveListingRequest)),
concatLatestFrom(() => this.store.select(selectListingRequestEntity)), tap(([, listingRequest]) => {
tap(([, requestEntity]) => { this.dialog.closeAll();
this.dialog.closeAll();
if (requestEntity) { if (listingRequest) {
this.queueService.deleteQueueListingRequest(requestEntity.listingRequest).subscribe(); this.queueService.deleteQueueListingRequest(listingRequest).subscribe({
} error: (errorResponse: HttpErrorResponse) => {
}) this.store.dispatch(ErrorActions.snackBarError({ error: errorResponse.error }));
), }
{ dispatch: false } });
}
}),
switchMap(() => of(QueueListingActions.deleteQueueListingRequestSuccess()))
)
); );
viewFlowFile$ = createEffect(() => viewFlowFile$ = createEffect(() =>
@ -222,10 +242,10 @@ export class QueueListingEffects {
} }
}) })
), ),
catchError((error) => catchError((errorResponse: HttpErrorResponse) =>
of( of(
QueueListingActions.queueListingApiError({ ErrorActions.snackBarError({
error: error.error error: errorResponse.error
}) })
) )
) )
@ -298,12 +318,13 @@ export class QueueListingEffects {
{ dispatch: false } { dispatch: false }
); );
queueListingApiError$ = createEffect( queueListingApiError$ = createEffect(() =>
() => this.actions$.pipe(
this.actions$.pipe( ofType(QueueListingActions.queueListingApiError),
ofType(QueueListingActions.queueListingApiError), tap(() => {
tap(() => this.dialog.closeAll()) this.store.dispatch(QueueListingActions.stopPollingQueueListingRequest());
), }),
{ dispatch: false } switchMap(({ error }) => of(ErrorActions.addBannerError({ error })))
)
); );
} }

View File

@ -23,14 +23,33 @@ import {
submitQueueListingRequestSuccess, submitQueueListingRequestSuccess,
resetQueueListingState, resetQueueListingState,
queueListingApiError, queueListingApiError,
loadConnectionLabelSuccess loadConnectionLabelSuccess,
deleteQueueListingRequestSuccess
} from './queue-listing.actions'; } from './queue-listing.actions';
import { produce } from 'immer';
export const initialState: QueueListingState = { export const initialState: QueueListingState = {
requestEntity: null, activeListingRequest: null,
completedListingRequest: {
id: '',
uri: '',
submissionTime: '',
lastUpdated: '',
percentCompleted: 100,
finished: true,
failureReason: '',
maxResults: 0,
sourceRunning: false,
destinationRunning: false,
state: '',
queueSize: {
objectCount: 0,
byteCount: 0
},
flowFileSummaries: []
},
connectionLabel: 'Connection', connectionLabel: 'Connection',
loadedTimestamp: 'N/A', loadedTimestamp: 'N/A',
error: null,
status: 'pending' status: 'pending'
}; };
@ -44,16 +63,25 @@ export const queueListingReducer = createReducer(
...state, ...state,
status: 'loading' as const status: 'loading' as const
})), })),
on(submitQueueListingRequestSuccess, pollQueueListingRequestSuccess, (state, { response }) => ({ on(submitQueueListingRequestSuccess, pollQueueListingRequestSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const listingRequest = response.requestEntity.listingRequest;
if (listingRequest.finished) {
draftState.completedListingRequest = listingRequest;
draftState.loadedTimestamp = listingRequest.lastUpdated;
draftState.status = 'success' as const;
} else {
draftState.activeListingRequest = listingRequest;
}
});
}),
on(deleteQueueListingRequestSuccess, (state) => ({
...state, ...state,
requestEntity: response.requestEntity, activeListingRequest: null
loadedTimestamp: response.requestEntity.listingRequest.lastUpdated,
error: null,
status: 'success' as const
})), })),
on(queueListingApiError, (state, { error }) => ({ on(queueListingApiError, (state) => ({
...state, ...state,
error,
status: 'error' as const status: 'error' as const
})), })),
on(resetQueueListingState, () => ({ on(resetQueueListingState, () => ({

View File

@ -25,15 +25,18 @@ export const selectQueueListingState = createSelector(
(state: QueueState) => state[queueListingFeatureKey] (state: QueueState) => state[queueListingFeatureKey]
); );
export const selectListingRequestEntity = createSelector( export const selectActiveListingRequest = createSelector(
selectQueueListingState, selectQueueListingState,
(state: QueueListingState) => state.requestEntity (state: QueueListingState) => state.activeListingRequest
);
export const selectCompletedListingRequest = createSelector(
selectQueueListingState,
(state: QueueListingState) => state.completedListingRequest
); );
export const selectStatus = createSelector(selectQueueListingState, (state: QueueListingState) => state.status); export const selectStatus = createSelector(selectQueueListingState, (state: QueueListingState) => state.status);
export const selectError = createSelector(selectQueueListingState, (state: QueueListingState) => state.error);
export const selectConnectionLabel = createSelector( export const selectConnectionLabel = createSelector(
selectQueueListingState, selectQueueListingState,
(state: QueueListingState) => state.connectionLabel (state: QueueListingState) => state.connectionLabel

View File

@ -17,6 +17,7 @@
<div class="flowfile-table h-full flex flex-col gap-y-2"> <div class="flowfile-table h-full flex flex-col gap-y-2">
<h3 class="text-xl bold queue-listing-header">{{ connectionLabel }}</h3> <h3 class="text-xl bold queue-listing-header">{{ connectionLabel }}</h3>
<error-banner></error-banner>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="value"> <div class="value">
Display {{ displayObjectCount }} of {{ formatCount(queueSizeObjectCount) }} ({{ Display {{ displayObjectCount }} of {{ formatCount(queueSizeObjectCount) }} ({{

View File

@ -20,14 +20,29 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlowFileTable } from './flowfile-table.component'; import { FlowFileTable } from './flowfile-table.component';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { Component } from '@angular/core';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../../state/error/error.reducer';
describe('FlowFileTable', () => { describe('FlowFileTable', () => {
let component: FlowFileTable; let component: FlowFileTable;
let fixture: ComponentFixture<FlowFileTable>; let fixture: ComponentFixture<FlowFileTable>;
@Component({
selector: 'error-banner',
standalone: true,
template: ''
})
class MockErrorBanner {}
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FlowFileTable, MatTableModule, BrowserAnimationsModule] imports: [FlowFileTable, MockErrorBanner, MatTableModule, BrowserAnimationsModule],
providers: [
provideMockStore({
initialState
})
]
}); });
fixture = TestBed.createComponent(FlowFileTable); fixture = TestBed.createComponent(FlowFileTable);
component = fixture.componentInstance; component = fixture.componentInstance;

View File

@ -25,12 +25,13 @@ import { NgForOf, NgIf } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { FlowFileSummary, ListingRequest } from '../../../state/queue-listing'; import { FlowFileSummary, ListingRequest } from '../../../state/queue-listing';
import { CurrentUser } from '../../../../../state/current-user'; import { CurrentUser } from '../../../../../state/current-user';
import { ErrorBanner } from '../../../../../ui/common/error-banner/error-banner.component';
@Component({ @Component({
selector: 'flowfile-table', selector: 'flowfile-table',
standalone: true, standalone: true,
templateUrl: './flowfile-table.component.html', templateUrl: './flowfile-table.component.html',
imports: [MatTableModule, NgForOf, NgIf, RouterLink], imports: [MatTableModule, NgForOf, NgIf, RouterLink, ErrorBanner],
styleUrls: ['./flowfile-table.component.scss'] styleUrls: ['./flowfile-table.component.scss']
}) })
export class FlowFileTable { export class FlowFileTable {

View File

@ -17,23 +17,20 @@
<div class="flex flex-col gap-y-2 h-full" *ngIf="status$ | async; let status"> <div class="flex flex-col gap-y-2 h-full" *ngIf="status$ | async; let status">
<div class="flex-1"> <div class="flex-1">
<div class="value" *ngIf="status === 'error'; else noError"> <ng-container *ngIf="listingRequest$ | async as listingRequest; else initialLoading">
{{ error$ | async }} <ng-container *ngIf="about$ | async as about">
</div>
<ng-template #noError>
<ng-container *ngIf="listingRequestEntity$ | async as entity; else initialLoading">
<flowfile-table <flowfile-table
[connectionLabel]="(connectionLabel$ | async)!" [connectionLabel]="(connectionLabel$ | async)!"
[listingRequest]="entity.listingRequest" [listingRequest]="listingRequest"
[currentUser]="(currentUser$ | async)!" [currentUser]="(currentUser$ | async)!"
[contentViewerAvailable]="contentViewerAvailable((about$ | async)!)" [contentViewerAvailable]="contentViewerAvailable(about)"
(viewFlowFile)="viewFlowFile($event)" (viewFlowFile)="viewFlowFile($event)"
(downloadContent)="downloadContent($event)" (downloadContent)="downloadContent($event)"
(viewContent)="viewContent($event)"></flowfile-table> (viewContent)="viewContent($event)"></flowfile-table>
</ng-container> </ng-container>
<ng-template #initialLoading> </ng-container>
<ngx-skeleton-loader count="3"></ngx-skeleton-loader> <ng-template #initialLoading>
</ng-template> <ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</ng-template> </ng-template>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">

View File

@ -21,8 +21,7 @@ import { distinctUntilChanged, filter } from 'rxjs';
import { import {
selectConnectionIdFromRoute, selectConnectionIdFromRoute,
selectConnectionLabel, selectConnectionLabel,
selectError, selectCompletedListingRequest,
selectListingRequestEntity,
selectLoadedTimestamp, selectLoadedTimestamp,
selectStatus selectStatus
} from '../../state/queue-listing/queue-listing.selectors'; } from '../../state/queue-listing/queue-listing.selectors';
@ -41,6 +40,7 @@ import { NiFiState } from '../../../../state';
import { selectAbout } from '../../../../state/about/about.selectors'; import { selectAbout } from '../../../../state/about/about.selectors';
import { About } from '../../../../state/about'; import { About } from '../../../../state/about';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { clearBannerErrors } from '../../../../state/error/error.actions';
@Component({ @Component({
selector: 'queue-listing', selector: 'queue-listing',
@ -49,10 +49,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
}) })
export class QueueListing implements OnDestroy { export class QueueListing implements OnDestroy {
status$ = this.store.select(selectStatus); status$ = this.store.select(selectStatus);
error$ = this.store.select(selectError);
connectionLabel$ = this.store.select(selectConnectionLabel); connectionLabel$ = this.store.select(selectConnectionLabel);
loadedTimestamp$ = this.store.select(selectLoadedTimestamp); loadedTimestamp$ = this.store.select(selectLoadedTimestamp);
listingRequestEntity$ = this.store.select(selectListingRequestEntity); listingRequest$ = this.store.select(selectCompletedListingRequest);
currentUser$ = this.store.select(selectCurrentUser); currentUser$ = this.store.select(selectCurrentUser);
about$ = this.store.select(selectAbout); about$ = this.store.select(selectAbout);
@ -104,5 +103,6 @@ export class QueueListing implements OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.store.dispatch(resetQueueListingState()); this.store.dispatch(resetQueueListingState());
this.store.dispatch(clearBannerErrors());
} }
} }

View File

@ -26,6 +26,7 @@ import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { queueFeatureKey, reducers } from '../../state'; import { queueFeatureKey, reducers } from '../../state';
import { QueueListingEffects } from '../../state/queue-listing/queue-listing.effects'; import { QueueListingEffects } from '../../state/queue-listing/queue-listing.effects';
import { ErrorBanner } from '../../../../ui/common/error-banner/error-banner.component';
@NgModule({ @NgModule({
declarations: [QueueListing], declarations: [QueueListing],
@ -37,7 +38,8 @@ import { QueueListingEffects } from '../../state/queue-listing/queue-listing.eff
NifiTooltipDirective, NifiTooltipDirective,
FlowFileTable, FlowFileTable,
StoreModule.forFeature(queueFeatureKey, reducers), StoreModule.forFeature(queueFeatureKey, reducers),
EffectsModule.forFeature(QueueListingEffects) EffectsModule.forFeature(QueueListingEffects),
ErrorBanner
] ]
}) })
export class QueueListingModule {} export class QueueListingModule {}

View File

@ -17,17 +17,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Client } from '../../../service/client.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ClusterSummaryService { export class ClusterSummaryService {
private static readonly API: string = '../nifi-api'; private static readonly API: string = '../nifi-api';
constructor( constructor(private httpClient: HttpClient) {}
private httpClient: HttpClient,
private client: Client
) {}
getClusterSummary(): Observable<any> { getClusterSummary(): Observable<any> {
return this.httpClient.get(`${ClusterSummaryService.API}/flow/cluster/summary`); return this.httpClient.get(`${ClusterSummaryService.API}/flow/cluster/summary`);

View File

@ -17,17 +17,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Client } from '../../../service/client.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ProcessGroupStatusService { export class ProcessGroupStatusService {
private static readonly API: string = '../nifi-api'; private static readonly API: string = '../nifi-api';
constructor( constructor(private httpClient: HttpClient) {}
private httpClient: HttpClient,
private client: Client
) {}
getProcessGroupsStatus(recursive?: boolean): Observable<any> { getProcessGroupsStatus(recursive?: boolean): Observable<any> {
if (recursive) { if (recursive) {

View File

@ -199,6 +199,5 @@ export interface SummaryListingState {
connectionStatusSnapshots: ConnectionStatusSnapshotEntity[]; connectionStatusSnapshots: ConnectionStatusSnapshotEntity[];
remoteProcessGroupStatusSnapshots: RemoteProcessGroupStatusSnapshotEntity[]; remoteProcessGroupStatusSnapshots: RemoteProcessGroupStatusSnapshotEntity[];
loadedTimestamp: string; loadedTimestamp: string;
error: string | null; status: 'pending' | 'loading' | 'success';
status: 'pending' | 'loading' | 'error' | 'success';
} }

View File

@ -37,11 +37,6 @@ export const loadSummaryListingSuccess = createAction(
props<{ response: SummaryListingResponse }>() props<{ response: SummaryListingResponse }>()
); );
export const summaryListingApiError = createAction(
`${SUMMARY_LISTING_PREFIX} Load Summary Listing error`,
props<{ error: string }>()
);
export const selectProcessorStatus = createAction( export const selectProcessorStatus = createAction(
`${SUMMARY_LISTING_PREFIX} Select Processor Status`, `${SUMMARY_LISTING_PREFIX} Select Processor Status`,
props<{ request: SelectProcessorStatusRequest }>() props<{ request: SelectProcessorStatusRequest }>()

View File

@ -16,7 +16,7 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state'; import { NiFiState } from '../../../../state';
import { ClusterSummaryService } from '../../service/cluster-summary.service'; import { ClusterSummaryService } from '../../service/cluster-summary.service';
@ -27,6 +27,9 @@ import * as StatusHistoryActions from '../../../../state/status-history/status-h
import { catchError, combineLatest, filter, map, of, switchMap, tap } from 'rxjs'; import { catchError, combineLatest, filter, map, of, switchMap, tap } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ComponentType } from '../../../../state/shared'; import { ComponentType } from '../../../../state/shared';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
import { selectSummaryListingStatus } from './summary-listing.selectors';
@Injectable() @Injectable()
export class SummaryListingEffects { export class SummaryListingEffects {
@ -35,6 +38,7 @@ export class SummaryListingEffects {
private store: Store<NiFiState>, private store: Store<NiFiState>,
private clusterSummaryService: ClusterSummaryService, private clusterSummaryService: ClusterSummaryService,
private pgStatusService: ProcessGroupStatusService, private pgStatusService: ProcessGroupStatusService,
private errorHelper: ErrorHelper,
private router: Router private router: Router
) {} ) {}
@ -42,7 +46,8 @@ export class SummaryListingEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(SummaryListingActions.loadSummaryListing), ofType(SummaryListingActions.loadSummaryListing),
map((action) => action.recursive), map((action) => action.recursive),
switchMap((recursive) => concatLatestFrom(() => this.store.select(selectSummaryListingStatus)),
switchMap(([recursive, listingStatus]) =>
combineLatest([ combineLatest([
this.clusterSummaryService.getClusterSummary(), this.clusterSummaryService.getClusterSummary(),
this.pgStatusService.getProcessGroupsStatus(recursive) this.pgStatusService.getProcessGroupsStatus(recursive)
@ -55,7 +60,9 @@ export class SummaryListingEffects {
} }
}) })
), ),
catchError((error) => of(SummaryListingActions.summaryListingApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) =>
of(this.errorHelper.handleLoadingError(listingStatus, errorResponse))
)
) )
) )
) )

View File

@ -25,12 +25,7 @@ import {
RemoteProcessGroupStatusSnapshotEntity, RemoteProcessGroupStatusSnapshotEntity,
SummaryListingState SummaryListingState
} from './index'; } from './index';
import { import { loadSummaryListing, loadSummaryListingSuccess, resetSummaryState } from './summary-listing.actions';
loadSummaryListing,
loadSummaryListingSuccess,
resetSummaryState,
summaryListingApiError
} from './summary-listing.actions';
export const initialState: SummaryListingState = { export const initialState: SummaryListingState = {
clusterSummary: null, clusterSummary: null,
@ -42,7 +37,6 @@ export const initialState: SummaryListingState = {
connectionStatusSnapshots: [], connectionStatusSnapshots: [],
remoteProcessGroupStatusSnapshots: [], remoteProcessGroupStatusSnapshots: [],
status: 'pending', status: 'pending',
error: null,
loadedTimestamp: '' loadedTimestamp: ''
}; };
@ -85,7 +79,6 @@ export const summaryListingReducer = createReducer(
return { return {
...state, ...state,
error: null,
status: 'success' as const, status: 'success' as const,
loadedTimestamp: response.status.processGroupStatus.statsLastRefreshed, loadedTimestamp: response.status.processGroupStatus.statsLastRefreshed,
processGroupStatus: response.status, processGroupStatus: response.status,
@ -99,12 +92,6 @@ export const summaryListingReducer = createReducer(
}; };
}), }),
on(summaryListingApiError, (state, { error }) => ({
...state,
error,
status: 'error' as const
})),
on(resetSummaryState, () => ({ on(resetSummaryState, () => ({
...initialState ...initialState
})) }))

View File

@ -116,6 +116,5 @@ export interface UserListingState {
userGroups: UserGroupEntity[]; userGroups: UserGroupEntity[];
saving: boolean; saving: boolean;
loadedTimestamp: string; loadedTimestamp: string;
error: string | null; status: 'pending' | 'loading' | 'success';
status: 'pending' | 'loading' | 'error' | 'success';
} }

View File

@ -44,7 +44,12 @@ export const loadTenantsSuccess = createAction(
props<{ response: LoadTenantsSuccess }>() props<{ response: LoadTenantsSuccess }>()
); );
export const usersApiError = createAction(`${USER_PREFIX} Users Api Error`, props<{ error: string }>()); export const usersApiSnackbarError = createAction(
`${USER_PREFIX} Users Api Snackbar Error`,
props<{ error: string }>()
);
export const usersApiBannerError = createAction(`${USER_PREFIX} Users Api Banner Error`, props<{ error: string }>());
export const openCreateTenantDialog = createAction(`${USER_PREFIX} Open Create Tenant Dialog`); export const openCreateTenantDialog = createAction(`${USER_PREFIX} Open Create Tenant Dialog`);

View File

@ -26,12 +26,15 @@ import { MatDialog } from '@angular/material/dialog';
import { UsersService } from '../../service/users.service'; import { UsersService } from '../../service/users.service';
import { YesNoDialog } from '../../../../ui/common/yes-no-dialog/yes-no-dialog.component'; import { YesNoDialog } from '../../../../ui/common/yes-no-dialog/yes-no-dialog.component';
import { EditTenantDialog } from '../../../../ui/common/edit-tenant/edit-tenant-dialog.component'; import { EditTenantDialog } from '../../../../ui/common/edit-tenant/edit-tenant-dialog.component';
import { selectSaving, selectUserGroups, selectUsers } from './user-listing.selectors'; import { selectSaving, selectStatus, selectUserGroups, selectUsers } from './user-listing.selectors';
import { EditTenantRequest, UserGroupEntity } from '../../../../state/shared'; import { EditTenantRequest, UserGroupEntity } from '../../../../state/shared';
import { selectTenant } from './user-listing.actions'; import { selectTenant } from './user-listing.actions';
import { Client } from '../../../../service/client.service'; import { Client } from '../../../../service/client.service';
import { NiFiCommon } from '../../../../service/nifi-common.service'; import { NiFiCommon } from '../../../../service/nifi-common.service';
import { UserAccessPolicies } from '../../ui/user-listing/user-access-policies/user-access-policies.component'; import { UserAccessPolicies } from '../../ui/user-listing/user-access-policies/user-access-policies.component';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
@Injectable() @Injectable()
export class UserListingEffects { export class UserListingEffects {
@ -44,13 +47,15 @@ export class UserListingEffects {
private store: Store<NiFiState>, private store: Store<NiFiState>,
private router: Router, private router: Router,
private usersService: UsersService, private usersService: UsersService,
private errorHelper: ErrorHelper,
private dialog: MatDialog private dialog: MatDialog
) {} ) {}
loadTenants$ = createEffect(() => loadTenants$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(UserListingActions.loadTenants), ofType(UserListingActions.loadTenants),
switchMap(() => concatLatestFrom(() => this.store.select(selectStatus)),
switchMap(([, status]) =>
combineLatest([this.usersService.getUsers(), this.usersService.getUserGroups()]).pipe( combineLatest([this.usersService.getUsers(), this.usersService.getUserGroups()]).pipe(
map(([usersResponse, userGroupsResponse]) => map(([usersResponse, userGroupsResponse]) =>
UserListingActions.loadTenantsSuccess({ UserListingActions.loadTenantsSuccess({
@ -61,12 +66,8 @@ export class UserListingEffects {
} }
}) })
), ),
catchError((error) => catchError((errorResponse: HttpErrorResponse) =>
of( of(this.errorHelper.handleLoadingError(status, errorResponse))
UserListingActions.usersApiError({
error: error.error
})
)
) )
) )
) )
@ -155,7 +156,10 @@ export class UserListingEffects {
} }
}) })
), ),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) => {
this.dialog.closeAll();
return of(UserListingActions.usersApiSnackbarError({ error: errorResponse.error }));
})
) )
) )
) )
@ -219,6 +223,22 @@ export class UserListingEffects {
) )
); );
usersApiBannerError$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.usersApiBannerError),
map((action) => action.error),
switchMap((error) => of(ErrorActions.addBannerError({ error })))
)
);
usersApiSnackbarError$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.usersApiSnackbarError),
map((action) => action.error),
switchMap((error) => of(ErrorActions.snackBarError({ error })))
)
);
awaitUpdateUserGroupsForCreateUser$ = createEffect(() => awaitUpdateUserGroupsForCreateUser$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(UserListingActions.createUserSuccess), ofType(UserListingActions.createUserSuccess),
@ -263,7 +283,10 @@ export class UserListingEffects {
} }
}) })
), ),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) => {
this.dialog.closeAll();
return of(UserListingActions.usersApiSnackbarError({ error: errorResponse.error }));
})
) )
) )
) )
@ -360,6 +383,8 @@ export class UserListingEffects {
}); });
dialogReference.afterClosed().subscribe(() => { dialogReference.afterClosed().subscribe(() => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch( this.store.dispatch(
selectTenant({ selectTenant({
id: request.user.id id: request.user.id
@ -385,7 +410,9 @@ export class UserListingEffects {
} }
}) })
), ),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) =>
of(UserListingActions.usersApiBannerError({ error: errorResponse.error }))
)
) )
) )
) )
@ -560,6 +587,8 @@ export class UserListingEffects {
}); });
dialogReference.afterClosed().subscribe(() => { dialogReference.afterClosed().subscribe(() => {
this.store.dispatch(ErrorActions.clearBannerErrors());
this.store.dispatch( this.store.dispatch(
selectTenant({ selectTenant({
id: request.userGroup.id id: request.userGroup.id
@ -585,7 +614,9 @@ export class UserListingEffects {
} }
}) })
), ),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) =>
of(UserListingActions.usersApiBannerError({ error: errorResponse.error }))
)
) )
) )
) )
@ -674,7 +705,9 @@ export class UserListingEffects {
switchMap((request) => switchMap((request) =>
from(this.usersService.deleteUser(request.user)).pipe( from(this.usersService.deleteUser(request.user)).pipe(
map(() => UserListingActions.loadTenants()), map(() => UserListingActions.loadTenants()),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) =>
of(UserListingActions.usersApiSnackbarError({ error: errorResponse.error }))
)
) )
) )
) )
@ -713,7 +746,9 @@ export class UserListingEffects {
switchMap((request) => switchMap((request) =>
from(this.usersService.deleteUserGroup(request.userGroup)).pipe( from(this.usersService.deleteUserGroup(request.userGroup)).pipe(
map(() => UserListingActions.loadTenants()), map(() => UserListingActions.loadTenants()),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error }))) catchError((errorResponse: HttpErrorResponse) =>
of(UserListingActions.usersApiSnackbarError({ error: errorResponse.error }))
)
) )
) )
) )

View File

@ -28,7 +28,9 @@ import {
updateUser, updateUser,
updateUserComplete, updateUserComplete,
updateUserGroup, updateUserGroup,
updateUserGroupSuccess updateUserGroupSuccess,
usersApiBannerError,
usersApiSnackbarError
} from './user-listing.actions'; } from './user-listing.actions';
export const initialState: UserListingState = { export const initialState: UserListingState = {
@ -36,7 +38,6 @@ export const initialState: UserListingState = {
userGroups: [], userGroups: [],
saving: false, saving: false,
loadedTimestamp: '', loadedTimestamp: '',
error: null,
status: 'pending' status: 'pending'
}; };
@ -54,7 +55,6 @@ export const userListingReducer = createReducer(
users: response.users, users: response.users,
userGroups: response.userGroups, userGroups: response.userGroups,
loadedTimestamp: response.loadedTimestamp, loadedTimestamp: response.loadedTimestamp,
error: null,
status: 'success' as const status: 'success' as const
})), })),
on(createUser, updateUser, createUserGroup, updateUserGroup, (state) => ({ on(createUser, updateUser, createUserGroup, updateUserGroup, (state) => ({
@ -76,5 +76,9 @@ export const userListingReducer = createReducer(
on(updateUserGroupSuccess, (state, { response }) => ({ on(updateUserGroupSuccess, (state, { response }) => ({
...state, ...state,
saving: response.requestId == null ? false : state.saving saving: response.requestId == null ? false : state.saving
})),
on(usersApiSnackbarError, usersApiBannerError, (state) => ({
...state,
saving: false
})) }))
); );

View File

@ -24,6 +24,8 @@ export const selectUserListingState = createSelector(selectUserState, (state: Us
export const selectSaving = createSelector(selectUserListingState, (state: UserListingState) => state.saving); export const selectSaving = createSelector(selectUserListingState, (state: UserListingState) => state.saving);
export const selectStatus = createSelector(selectUserListingState, (state: UserListingState) => state.status);
export const selectUsers = createSelector(selectUserListingState, (state: UserListingState) => state.users); export const selectUsers = createSelector(selectUserListingState, (state: UserListingState) => state.users);
export const selectUserGroups = createSelector(selectUserListingState, (state: UserListingState) => state.userGroups); export const selectUserGroups = createSelector(selectUserListingState, (state: UserListingState) => state.userGroups);

View File

@ -17,6 +17,7 @@
<h2 mat-dialog-title>{{ isNew ? 'Add' : 'Edit' }} {{ isUser ? 'User' : 'User Group' }}</h2> <h2 mat-dialog-title>{{ isNew ? 'Add' : 'Edit' }} {{ isUser ? 'User' : 'User Group' }}</h2>
<form class="edit-tenant-form" [formGroup]="editTenantForm"> <form class="edit-tenant-form" [formGroup]="editTenantForm">
<error-banner></error-banner>
<mat-dialog-content> <mat-dialog-content>
<div class="mb-6"> <div class="mb-6">
<mat-radio-group formControlName="tenantType" (change)="tenantTypeChanged()"> <mat-radio-group formControlName="tenantType" (change)="tenantTypeChanged()">

View File

@ -21,6 +21,9 @@ import { EditTenantDialog } from './edit-tenant-dialog.component';
import { EditTenantRequest } from '../../../state/shared'; import { EditTenantRequest } from '../../../state/shared';
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { Component } from '@angular/core';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../state/error/error.reducer';
describe('EditTenantDialog', () => { describe('EditTenantDialog', () => {
let component: EditTenantDialog; let component: EditTenantDialog;
@ -782,10 +785,22 @@ describe('EditTenantDialog', () => {
] ]
}; };
@Component({
selector: 'error-banner',
standalone: true,
template: ''
})
class MockErrorBanner {}
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [EditTenantDialog, BrowserAnimationsModule], imports: [EditTenantDialog, MockErrorBanner, BrowserAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }] providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore({
initialState
})
]
}); });
fixture = TestBed.createComponent(EditTenantDialog); fixture = TestBed.createComponent(EditTenantDialog);
component = fixture.componentInstance; component = fixture.componentInstance;

View File

@ -40,6 +40,7 @@ import { Observable } from 'rxjs';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import { Client } from '../../../service/client.service'; import { Client } from '../../../service/client.service';
import { NiFiCommon } from '../../../service/nifi-common.service'; import { NiFiCommon } from '../../../service/nifi-common.service';
import { ErrorBanner } from '../error-banner/error-banner.component';
@Component({ @Component({
selector: 'edit-tenant-dialog', selector: 'edit-tenant-dialog',
@ -57,7 +58,8 @@ import { NiFiCommon } from '../../../service/nifi-common.service';
NgIf, NgIf,
AsyncPipe, AsyncPipe,
MatListModule, MatListModule,
NgForOf NgForOf,
ErrorBanner
], ],
templateUrl: './edit-tenant-dialog.component.html', templateUrl: './edit-tenant-dialog.component.html',
styleUrls: ['./edit-tenant-dialog.component.scss'] styleUrls: ['./edit-tenant-dialog.component.scss']