[NIFI-12560] bulletin board (#8204)

* [NIFI-12560] bulletin board

* Add sourceType to BulletinDTO

* remove unused import

* address review feedback: fix overflow for bulletins tooltip.
This commit is contained in:
Rob Fellows 2024-01-08 15:13:57 -05:00 committed by GitHub
parent 1c26b39fcd
commit 726a930b01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1262 additions and 5 deletions

View File

@ -38,6 +38,7 @@ public class BulletinDTO {
private String level;
private String message;
private Date timestamp;
private String sourceType;
/**
* @return id of this message
@ -167,4 +168,14 @@ public class BulletinDTO {
this.timestamp = timestamp;
}
@ApiModelProperty(
value = "The type of the source component"
)
public String getSourceType() {
return sourceType;
}
public void setSourceType(String sourceType) {
this.sourceType = sourceType;
}
}

View File

@ -3417,6 +3417,7 @@ public final class DtoFactory {
dto.setCategory(bulletin.getCategory());
dto.setLevel(bulletin.getLevel());
dto.setMessage(bulletin.getMessage());
dto.setSourceType(bulletin.getSourceType().name());
return dto;
}
@ -4469,6 +4470,7 @@ public final class DtoFactory {
copy.setLevel(original.getLevel());
copy.setMessage(original.getMessage());
copy.setNodeAddress(original.getNodeAddress());
copy.setSourceType(original.getSourceType());
return copy;
}

View File

@ -57,6 +57,11 @@ const routes: Routes = [
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/summary/feature/summary.module').then((m) => m.SummaryModule)
},
{
path: 'bulletins',
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/bulletins/feature/bulletins.module').then((m) => m.BulletinsModule)
},
{
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 { NgModule } from '@angular/core';
import { Bulletins } from './bulletins.component';
const routes: Routes = [
{
path: '',
component: Bulletins
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BulletinsRoutingModule {}

View File

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

View File

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

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

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 { Bulletins } from './bulletins.component';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import { bulletinsFeatureKey, reducers } from '../state';
import { EffectsModule } from '@ngrx/effects';
import { BulletinBoardEffects } from '../state/bulletin-board/bulletin-board.effects';
import { BulletinsRoutingModule } from './bulletins-routing.module';
import { CounterListingModule } from '../../counters/ui/counter-listing/counter-listing.module';
import { BulletinBoard } from '../ui/bulletin-board/bulletin-board.component';
@NgModule({
declarations: [Bulletins],
exports: [Bulletins],
imports: [
CommonModule,
BulletinsRoutingModule,
StoreModule.forFeature(bulletinsFeatureKey, reducers),
EffectsModule.forFeature(BulletinBoardEffects),
CounterListingModule,
BulletinBoard
]
})
export class BulletinsModule {}

View File

@ -0,0 +1,36 @@
/*
* 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, HttpParams } from '@angular/common/http';
import { Client } from '../../../service/client.service';
import { LoadBulletinBoardRequest } from '../state/bulletin-board';
@Injectable({ providedIn: 'root' })
export class BulletinBoardService {
private static readonly API: string = '../nifi-api';
constructor(
private httpClient: HttpClient,
private client: Client
) {}
getBulletins(request: LoadBulletinBoardRequest) {
const params: HttpParams = request as HttpParams;
return this.httpClient.get(`${BulletinBoardService.API}/flow/bulletin-board`, { params });
}
}

View File

@ -0,0 +1,54 @@
/*
* 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 { BulletinBoardFilterArgs, LoadBulletinBoardRequest, LoadBulletinBoardResponse } from './index';
const BULLETIN_BOARD_PREFIX = '[Bulletin Board]';
export const loadBulletinBoard = createAction(
`${BULLETIN_BOARD_PREFIX} Load Bulletin Board`,
props<{ request: LoadBulletinBoardRequest }>()
);
export const loadBulletinBoardSuccess = createAction(
`${BULLETIN_BOARD_PREFIX} Load Bulletin Board Success`,
props<{ response: LoadBulletinBoardResponse }>()
);
export const resetBulletinBoardState = createAction(`${BULLETIN_BOARD_PREFIX} Reset Bulletin Board State`);
export const clearBulletinBoard = createAction(`${BULLETIN_BOARD_PREFIX} Clear Bulletin Board`);
export const bulletinBoardApiError = createAction(
`${BULLETIN_BOARD_PREFIX} Load Bulletin Board Errors`,
props<{ error: string }>()
);
export const setBulletinBoardFilter = createAction(
`${BULLETIN_BOARD_PREFIX} Set Bulletin Board Filter`,
props<{ filter: BulletinBoardFilterArgs }>()
);
export const setBulletinBoardAutoRefresh = createAction(
`${BULLETIN_BOARD_PREFIX} Set Auto-Refresh`,
props<{ autoRefresh: boolean }>()
);
export const startBulletinBoardPolling = createAction(`${BULLETIN_BOARD_PREFIX} Start polling`);
export const stopBulletinBoardPolling = createAction(`${BULLETIN_BOARD_PREFIX} Stop polling`);

View File

@ -0,0 +1,107 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { Router } from '@angular/router';
import * as BulletinBoardActions from './bulletin-board.actions';
import { asyncScheduler, from, interval, map, of, switchMap, takeUntil, withLatestFrom } from 'rxjs';
import { BulletinBoardService } from '../../service/bulletin-board.service';
import { selectBulletinBoardFilter, selectLastBulletinId } from './bulletin-board.selectors';
import { LoadBulletinBoardRequest } from './index';
@Injectable()
export class BulletinBoardEffects {
constructor(
private actions$: Actions,
private store: Store<NiFiState>,
private router: Router,
private bulletinBoardService: BulletinBoardService
) {}
loadBulletinBoard$ = createEffect(() =>
this.actions$.pipe(
ofType(BulletinBoardActions.loadBulletinBoard),
map((action) => action.request),
switchMap((request) =>
from(
this.bulletinBoardService.getBulletins(request).pipe(
map((response: any) =>
BulletinBoardActions.loadBulletinBoardSuccess({
response: {
bulletinBoard: response.bulletinBoard,
loadedTimestamp: response.bulletinBoard.generated
}
})
)
)
)
)
)
);
setBulletinBoardAutoRefresh$ = createEffect(() =>
this.actions$.pipe(
ofType(BulletinBoardActions.setBulletinBoardAutoRefresh),
map((action) => action.autoRefresh),
switchMap((autoRefresh) => {
if (autoRefresh) {
return of(BulletinBoardActions.startBulletinBoardPolling());
}
return of(BulletinBoardActions.stopBulletinBoardPolling());
})
)
);
startBulletinBoardPolling$ = createEffect(() =>
this.actions$.pipe(
ofType(BulletinBoardActions.startBulletinBoardPolling),
switchMap(() =>
interval(3000, asyncScheduler).pipe(
takeUntil(this.actions$.pipe(ofType(BulletinBoardActions.stopBulletinBoardPolling)))
)
),
withLatestFrom(this.store.select(selectBulletinBoardFilter), this.store.select(selectLastBulletinId)),
switchMap(([, filter, lastBulletinId]) => {
const request: LoadBulletinBoardRequest = {};
if (lastBulletinId > 0) {
request.after = lastBulletinId;
}
if (filter.filterTerm.length > 0) {
const filterTerm = filter.filterTerm;
switch (filter.filterColumn) {
case 'message':
request.message = filterTerm;
break;
case 'id':
request.sourceId = filterTerm;
break;
case 'groupId':
request.groupId = filterTerm;
break;
case 'name':
request.sourceName = filterTerm;
break;
}
}
return of(BulletinBoardActions.loadBulletinBoard({ request }));
})
)
);
}

View File

@ -0,0 +1,117 @@
/*
* 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 { BulletinBoardEvent, BulletinBoardItem, BulletinBoardState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
bulletinBoardApiError,
clearBulletinBoard,
loadBulletinBoard,
loadBulletinBoardSuccess,
resetBulletinBoardState,
setBulletinBoardAutoRefresh,
setBulletinBoardFilter
} from './bulletin-board.actions';
export const initialBulletinBoardState: BulletinBoardState = {
bulletinBoardItems: [],
filter: {
filterTerm: '',
filterColumn: 'message'
},
autoRefresh: true,
lastBulletinId: 0,
status: 'pending',
error: null,
loadedTimestamp: ''
};
export const bulletinBoardReducer = createReducer(
initialBulletinBoardState,
on(loadBulletinBoard, (state: BulletinBoardState) => ({
...state,
status: 'loading' as const
})),
on(loadBulletinBoardSuccess, (state: BulletinBoardState, { response }) => {
// as bulletins are loaded, we just add them to the existing bulletins in the store
const items = response.bulletinBoard.bulletins.map((bulletin) => {
const bulletinItem: BulletinBoardItem = {
item: bulletin
};
return bulletinItem;
});
let lastId = state.lastBulletinId;
if (response.bulletinBoard.bulletins.length > 0) {
lastId = response.bulletinBoard.bulletins[response.bulletinBoard.bulletins.length - 1].id;
}
return {
...state,
bulletinBoardItems: [...state.bulletinBoardItems, ...items],
lastBulletinId: lastId,
status: 'success' as const,
error: null,
loadedTimestamp: response.loadedTimestamp
};
}),
on(bulletinBoardApiError, (state, { error }) => ({
...state,
error,
status: 'error' as const
})),
on(resetBulletinBoardState, () => ({ ...initialBulletinBoardState })),
on(clearBulletinBoard, (state) => ({
...state,
bulletinBoardItems: []
})),
on(setBulletinBoardFilter, (state: BulletinBoardState, { filter }) => {
// add a new bulletin event to the list for the filter
const event: BulletinBoardEvent = {
type: 'filter',
message:
filter.filterTerm.length > 0
? `Filter by ${filter.filterColumn} matching '${filter.filterTerm}'`
: 'Filter removed'
};
const item: BulletinBoardItem = { item: event };
return {
...state,
bulletinBoardItems: [...state.bulletinBoardItems, item],
filter: { ...filter }
};
}),
on(setBulletinBoardAutoRefresh, (state: BulletinBoardState, { autoRefresh }) => {
const event: BulletinBoardEvent = {
type: 'auto-refresh',
message: autoRefresh ? `Auto-refresh started` : 'Auto-refresh stopped'
};
const item: BulletinBoardItem = { item: event };
return {
...state,
bulletinBoardItems: [...state.bulletinBoardItems, item],
autoRefresh
};
})
);

View File

@ -0,0 +1,35 @@
/*
* 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 { BulletinsState, selectBulletinState } from '../index';
import { bulletinBoardFeatureKey, BulletinBoardState } from './index';
export const selectBulletinBoardState = createSelector(
selectBulletinState,
(state: BulletinsState) => state[bulletinBoardFeatureKey]
);
export const selectLastBulletinId = createSelector(
selectBulletinBoardState,
(state: BulletinBoardState) => state.lastBulletinId
);
export const selectBulletinBoardFilter = createSelector(
selectBulletinBoardState,
(state: BulletinBoardState) => state.filter
);

View File

@ -0,0 +1,63 @@
/*
* 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 { BulletinEntity } from '../../../../state/shared';
export const bulletinBoardFeatureKey = 'bulletin-board';
export interface BulletinBoardEntity {
bulletins: BulletinEntity[];
generated: string;
}
export interface BulletinBoardEvent {
type: 'auto-refresh' | 'filter';
message: string;
}
export interface BulletinBoardItem {
item: BulletinEntity | BulletinBoardEvent;
}
export interface BulletinBoardState {
bulletinBoardItems: BulletinBoardItem[];
filter: BulletinBoardFilterArgs;
autoRefresh: boolean;
lastBulletinId: number;
loadedTimestamp: string;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}
export interface LoadBulletinBoardResponse {
bulletinBoard: BulletinBoardEntity;
loadedTimestamp: string;
}
export interface LoadBulletinBoardRequest {
after?: number;
limit?: number;
groupId?: string;
sourceId?: string;
sourceName?: string;
message?: string;
}
export interface BulletinBoardFilterArgs {
filterTerm: string;
filterColumn: string;
}

View File

@ -0,0 +1,34 @@
/*
* 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 { bulletinBoardFeatureKey, BulletinBoardState } from './bulletin-board';
import { Action, combineReducers, createFeatureSelector } from '@ngrx/store';
import { bulletinBoardReducer } from './bulletin-board/bulletin-board.reducer';
export const bulletinsFeatureKey = 'bulletins';
export interface BulletinsState {
[bulletinBoardFeatureKey]: BulletinBoardState;
}
export function reducers(state: BulletinsState | undefined, action: Action) {
return combineReducers({
[bulletinBoardFeatureKey]: bulletinBoardReducer
})(state, action);
}
export const selectBulletinState = createFeatureSelector<BulletinsState>(bulletinsFeatureKey);

View File

@ -0,0 +1,83 @@
<!--
~ 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="bulletin-board-list-container h-full flex flex-col">
<div class="bulletin-board-list-filter-container">
<form [formGroup]="filterForm">
<div class="flex pt-2">
<div class="mr-2">
<mat-form-field>
<mat-label>Filter</mat-label>
<input matInput type="text" class="small" formControlName="filterTerm" />
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Filter By</mat-label>
<mat-select formControlName="filterColumn">
<mat-option value="message"> message </mat-option>
<mat-option value="name"> name </mat-option>
<mat-option value="id"> id </mat-option>
<mat-option value="groupId"> group id </mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</form>
</div>
<div class="flex-1 relative">
<div class="bulletin-board-list overflow-y-auto absolute inset-0 border-2 p-4" #scrollContainer>
<ul class="flex flex-wrap gap-y-2">
<ng-container *ngFor="let item of bulletinBoardItems">
<!-- each item can either be a BulletinEntity or BulletinBoardEvent -->
<ng-container *ngIf="isBulletin(item); else bulletinEvent">
<ng-container *ngIf="asBulletin(item); let bulletin">
<li *ngIf="bulletin.canRead">
<div class="inline-flex flex-wrap gap-x-1.5">
<div>{{ bulletin.timestamp }}</div>
<div class="font-bold {{ getSeverity(bulletin.bulletin.level) }}">
{{ bulletin.bulletin.level }}
</div>
<div *ngIf="getRouterLink(bulletin); let link; else: noLink">
<a class="link" [routerLink]="link">{{ bulletin.bulletin.sourceId }}</a>
</div>
<ng-template #noLink>
<div>{{ bulletin.bulletin.sourceId }}</div>
</ng-template>
<div *ngIf="!!bulletin.nodeAddress">{{ bulletin.nodeAddress }}</div>
<pre class="whitespace-pre-wrap">{{ bulletin.bulletin.message }}</pre>
</div>
</li>
</ng-container>
</ng-container>
<ng-template #bulletinEvent>
<ng-container *ngIf="asBulletinEvent(item); let event">
<li class="w-full mt-4">
<div class="border-b-2 flex-1 p-2">
{{ event.message }}
</div>
</li>
</ng-container>
</ng-template>
</ng-container>
</ul>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
/*!
* 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.
*/
.bulletin-error {
color: #ec3030;
}
.bulletin-warn {
color: #bda500;
}
.bulletin-normal {
color: #016131;
}
.link {
font-size: 16px;
}

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

View File

@ -0,0 +1,209 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { BulletinBoardEvent, BulletinBoardFilterArgs, BulletinBoardItem } from '../../../state/bulletin-board';
import { BulletinEntity, ComponentType } from '../../../../../state/shared';
import { debounceTime, delay, Subject } from 'rxjs';
import { RouterLink } from '@angular/router';
@Component({
selector: 'bulletin-board-list',
standalone: true,
imports: [
CommonModule,
MatFormFieldModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
ReactiveFormsModule,
RouterLink
],
templateUrl: './bulletin-board-list.component.html',
styleUrls: ['./bulletin-board-list.component.scss']
})
export class BulletinBoardList implements AfterViewInit {
filterTerm = '';
filterColumn: 'message' | 'name' | 'id' | 'groupId' = 'message';
filterForm: FormGroup;
private bulletinsChanged$: Subject<void> = new Subject<void>();
private _items: BulletinBoardItem[] = [];
@ViewChild('scrollContainer') private scroll!: ElementRef;
@Input() set bulletinBoardItems(items: BulletinBoardItem[]) {
this._items = items;
this.bulletinsChanged$.next();
}
get bulletinBoardItems(): BulletinBoardItem[] {
return this._items;
}
@Output() filterChanged: EventEmitter<BulletinBoardFilterArgs> = new EventEmitter<BulletinBoardFilterArgs>();
constructor(
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon
) {
this.filterForm = this.formBuilder.group({ filterTerm: '', filterColumn: 'message' });
}
ngAfterViewInit(): void {
this.filterForm
.get('filterTerm')
?.valueChanges.pipe(debounceTime(500))
.subscribe((filterTerm: string) => {
const filterColumn = this.filterForm.get('filterColumn')?.value;
this.applyFilter(filterTerm, filterColumn);
});
this.filterForm.get('filterColumn')?.valueChanges.subscribe((filterColumn: string) => {
const filterTerm = this.filterForm.get('filterTerm')?.value;
this.applyFilter(filterTerm, filterColumn);
});
// scroll the initial chuck of bulletins
this.scrollToBottom();
this.bulletinsChanged$
.pipe(delay(10)) // allow the new data a chance to render so the sizing of the scroll container is correct
.subscribe(() => {
// auto-scroll
this.scrollToBottom();
});
}
private scrollToBottom() {
if (this.scroll) {
this.scroll.nativeElement.scroll({
top: this.scroll.nativeElement.scrollHeight,
left: 0,
behavior: 'smooth'
});
}
}
applyFilter(filterTerm: string, filterColumn: string) {
this.filterChanged.next({
filterColumn,
filterTerm
});
}
isBulletin(bulletinBoardItem: BulletinBoardItem): boolean {
const item = bulletinBoardItem.item;
return !('type' in item);
}
asBulletin(bulletinBoardItem: BulletinBoardItem): BulletinEntity | null {
if (this.isBulletin(bulletinBoardItem)) {
return bulletinBoardItem.item as BulletinEntity;
}
return null;
}
asBulletinEvent(bulletinBoardItem: BulletinBoardItem): BulletinBoardEvent | null {
if (!this.isBulletin(bulletinBoardItem)) {
return bulletinBoardItem.item as BulletinBoardEvent;
}
return null;
}
getSeverity(severity: string) {
switch (severity.toLowerCase()) {
case 'error':
return 'bulletin-error';
case 'warn':
case 'warning':
return 'bulletin-warn';
default:
return 'bulletin-normal';
}
}
getRouterLink(bulletin: BulletinEntity): string[] | null {
const type: ComponentType | null = this.getComponentType(bulletin.bulletin.sourceType);
if (type && bulletin.sourceId) {
if (type === ComponentType.ControllerService) {
if (bulletin.groupId) {
// process group controller service
return ['/process-groups', bulletin.groupId, 'controller-services', bulletin.sourceId];
} else {
// management controller service
return ['/settings', 'management-controller-services', bulletin.sourceId];
}
}
if (type === ComponentType.ReportingTask) {
return ['/settings', 'reporting-tasks', bulletin.sourceId];
}
if (type === ComponentType.FlowRegistryClient) {
return ['/settings', 'registry-clients', bulletin.sourceId];
}
if (type === ComponentType.FlowAnalysisRule) {
return ['/settings', 'flow-analysis-rules', bulletin.sourceId];
}
if (type === ComponentType.ParameterProvider) {
return ['/settings', 'parameter-providers', bulletin.sourceId];
}
if (bulletin.groupId) {
return ['/process-groups', bulletin.groupId, type, bulletin.sourceId];
}
}
return null;
}
private getComponentType(sourceType: string): ComponentType | null {
switch (sourceType) {
case 'PROCESSOR':
return ComponentType.Processor;
case 'REMOTE_PROCESS_GROUP':
return ComponentType.RemoteProcessGroup;
case 'INPUT_PORT':
return ComponentType.InputPort;
case 'OUTPUT_PORT':
return ComponentType.OutputPort;
case 'FUNNEL':
return ComponentType.Funnel;
case 'CONTROLLER_SERVICE':
return ComponentType.ControllerService;
case 'REPORTING_TASK':
return ComponentType.ReportingTask;
case 'FLOW_ANALYSIS_RULE':
return ComponentType.FlowAnalysisRule;
case 'PARAMETER_PROVIDER':
return ComponentType.ParameterProvider;
case 'FLOW_REGISTRY_CLIENT':
return ComponentType.FlowRegistryClient;
default:
return null;
}
}
}

View File

@ -0,0 +1,49 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<ng-container *ngIf="bulletinBoardState$ | async; let bulletinBoardState">
<div *ngIf="isInitialLoading(bulletinBoardState); else loaded">
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
<ng-template #loaded>
<div class="bulletin-board flex flex-col h-full gap-y-2">
<div class="flex-1">
<bulletin-board-list
(filterChanged)="applyFilter($event)"
[bulletinBoardItems]="bulletinBoardState.bulletinBoardItems"></bulletin-board-list>
</div>
<div class="flex justify-between">
<div class="refresh-container flex items-center gap-x-2">
<div class="mr-6">
<mat-slide-toggle [checked]="autoRefresh" (change)="autoRefreshToggle($event)"
>Auto-refresh</mat-slide-toggle
>
</div>
<button class="nifi-button" (click)="refreshBulletinBoard(bulletinBoardState.lastBulletinId)">
<i class="fa fa-refresh" [class.fa-spin]="bulletinBoardState.status === 'loading'"></i>
</button>
<div>Last updated:</div>
<div class="refresh-timestamp">{{ bulletinBoardState.loadedTimestamp }}</div>
</div>
<div class="clear-bulletin-board-container">
<a (click)="clear()">Clear</a>
</div>
</div>
</div>
</ng-template>
</ng-container>

View File

@ -0,0 +1,19 @@
/*!
* 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.
*/
.bulletin-board {
}

View File

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

View File

@ -0,0 +1,138 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { BulletinBoardFilterArgs, BulletinBoardState, LoadBulletinBoardRequest } from '../../state/bulletin-board';
import {
selectBulletinBoardFilter,
selectBulletinBoardState
} from '../../state/bulletin-board/bulletin-board.selectors';
import {
clearBulletinBoard,
loadBulletinBoard,
resetBulletinBoardState,
setBulletinBoardAutoRefresh,
setBulletinBoardFilter,
startBulletinBoardPolling,
stopBulletinBoardPolling
} from '../../state/bulletin-board/bulletin-board.actions';
import { initialBulletinBoardState } from '../../state/bulletin-board/bulletin-board.reducer';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
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 { ReactiveFormsModule } from '@angular/forms';
import { BulletinBoardList } from './bulletin-board-list/bulletin-board-list.component';
import { MatSlideToggleChange, MatSlideToggleModule } from '@angular/material/slide-toggle';
@Component({
selector: 'bulletin-board',
standalone: true,
imports: [
CommonModule,
NgxSkeletonLoaderModule,
MatFormFieldModule,
MatInputModule,
MatOptionModule,
MatSelectModule,
ReactiveFormsModule,
BulletinBoardList,
MatSlideToggleModule
],
templateUrl: './bulletin-board.component.html',
styleUrls: ['./bulletin-board.component.scss']
})
export class BulletinBoard implements OnInit, OnDestroy {
bulletinBoardState$ = this.store.select(selectBulletinBoardState);
private bulletinBoardFilter$ = this.store.select(selectBulletinBoardFilter);
private currentFilter: BulletinBoardFilterArgs | null = null;
autoRefresh = true;
constructor(private store: Store<BulletinBoardState>) {}
ngOnInit(): void {
this.store.dispatch(
loadBulletinBoard({
request: {
limit: 10
}
})
);
this.bulletinBoardFilter$.subscribe((filter) => (this.currentFilter = filter));
this.store.dispatch(startBulletinBoardPolling());
}
ngOnDestroy(): void {
this.store.dispatch(resetBulletinBoardState());
this.store.dispatch(stopBulletinBoardPolling());
}
isInitialLoading(state: BulletinBoardState): boolean {
return state.loadedTimestamp == initialBulletinBoardState.loadedTimestamp;
}
refreshBulletinBoard(lastBulletinId: number) {
const request: LoadBulletinBoardRequest = {
after: lastBulletinId
};
if (this.currentFilter) {
if (this.currentFilter.filterTerm?.length > 0) {
const filterTerm = this.currentFilter.filterTerm;
switch (this.currentFilter.filterColumn) {
case 'message':
request.message = filterTerm;
break;
case 'id':
request.sourceId = filterTerm;
break;
case 'groupId':
request.groupId = filterTerm;
break;
case 'name':
request.sourceName = filterTerm;
break;
}
}
}
this.store.dispatch(loadBulletinBoard({ request }));
}
clear() {
this.store.dispatch(clearBulletinBoard());
}
applyFilter(filter: BulletinBoardFilterArgs) {
// only fire the filter changed event if there is something different
if (
filter.filterTerm.length > 0 ||
(filter.filterTerm.length === 0 && filter.filterColumn === this.currentFilter?.filterColumn)
) {
this.store.dispatch(setBulletinBoardFilter({ filter }));
}
}
autoRefreshToggle(event: MatSlideToggleChange) {
this.autoRefresh = event.checked;
this.store.dispatch(setBulletinBoardAutoRefresh({ autoRefresh: this.autoRefresh }));
}
}

View File

@ -76,7 +76,7 @@
<i class="icon fa-fw icon-counter mr-2"></i>
Counter
</button>
<button mat-menu-item class="global-menu-item">
<button mat-menu-item class="global-menu-item" [routerLink]="['/bulletins']">
<i class="fa fa-fw fa-sticky-note-o mr-2"></i>
Bulletin Board
</button>

View File

@ -269,6 +269,7 @@ export interface BulletinEntity {
sourceName: string;
timestamp: string;
nodeAddress?: string;
sourceType: string;
};
}
@ -367,7 +368,12 @@ export enum ComponentType {
OutputPort = 'OutputPort',
Label = 'Label',
Funnel = 'Funnel',
Connection = 'Connection'
Connection = 'Connection',
ControllerService = 'ControllerService',
ReportingTask = 'ReportingTask',
FlowAnalysisRule = 'FlowAnalysisRule',
ParameterProvider = 'ParameterProvider',
FlowRegistryClient = 'FlowRegistryClient'
}
export interface ControllerServiceReferencingComponent {

View File

@ -21,7 +21,7 @@ import { DisableControllerService } from './disable-controller-service.component
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../state/contoller-service-state/controller-service-state.reducer';
import { SetEnableControllerServiceDialogRequest } from '../../../../state/shared';
import { ComponentType, SetEnableControllerServiceDialogRequest } from '../../../../state/shared';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
describe('EnableControllerService', () => {
@ -54,6 +54,7 @@ describe('EnableControllerService', () => {
groupId: 'asdf',
sourceId: 'asdf',
sourceName: 'asdf',
sourceType: ComponentType.Processor,
level: 'ERROR',
message: 'asdf',
timestamp: '14:08:44 EST'

View File

@ -22,7 +22,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../state/contoller-service-state/controller-service-state.reducer';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { SetEnableControllerServiceDialogRequest } from '../../../../state/shared';
import { ComponentType, SetEnableControllerServiceDialogRequest } from '../../../../state/shared';
describe('EnableControllerService', () => {
let component: EnableControllerService;
@ -54,6 +54,7 @@ describe('EnableControllerService', () => {
groupId: 'asdf',
sourceId: 'asdf',
sourceName: 'asdf',
sourceType: ComponentType.Processor,
level: 'ERROR',
message: 'asdf',
timestamp: '14:08:44 EST'

View File

@ -15,7 +15,7 @@
~ limitations under the License.
-->
<div class="tooltip" [style.left.px]="left" [style.top.px]="top">
<div class="tooltip overflow-auto" [style.left.px]="left" [style.top.px]="top">
<ul class="flex flex-wrap gap-y-1">
<ng-container *ngFor="let bulletinEntity of data?.bulletins">
<li *ngIf="bulletinEntity.canRead">