Config-editor-ui: add management view with links and actions (#494)

This commit is contained in:
Celie Valentiny
2022-01-28 13:55:32 +00:00
committed by GitHub
parent 923f7f9af3
commit ece42d617e
32 changed files with 447 additions and 120 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"name": "rule-editor.ui", "name": "rule-editor.ui",
"version": "2.2.4-dev", "version": "2.2.5-dev",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
@@ -20,17 +20,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-devkit/build-angular": "^13.1.3", "@angular-devkit/build-angular": "^13.2.0",
"@angular/animations": "^13.1.2", "@angular/animations": "^13.1.2",
"@angular/cdk": "^13.1.2", "@angular/cdk": "^13.2.0",
"@angular/common": "^13.1.2", "@angular/common": "^13.2.0",
"@angular/compiler": "^13.1.2", "@angular/compiler": "^13.2.0",
"@angular/core": "^13.1.2", "@angular/core": "^13.2.0",
"@angular/forms": "^13.1.2", "@angular/forms": "^13.2.0",
"@angular/material": "^13.1.2", "@angular/material": "^13.2.0",
"@angular/platform-browser": "^13.1.2", "@angular/platform-browser": "^13.2.0",
"@angular/platform-browser-dynamic": "^13.1.2", "@angular/platform-browser-dynamic": "^13.2.0",
"@angular/router": "^13.1.2", "@angular/router": "^13.2.0",
"@ngx-formly/core": "^6.0.0-next.6", "@ngx-formly/core": "^6.0.0-next.6",
"@ngx-formly/material": "^6.0.0-next.6", "@ngx-formly/material": "^6.0.0-next.6",
"@types/json-schema": "^7.0.8", "@types/json-schema": "^7.0.8",
@@ -57,23 +57,23 @@
"@angular-eslint/eslint-plugin": "^13.0.1", "@angular-eslint/eslint-plugin": "^13.0.1",
"@angular-eslint/eslint-plugin-template": "^13.0.1", "@angular-eslint/eslint-plugin-template": "^13.0.1",
"@angular-eslint/template-parser": "^13.0.1", "@angular-eslint/template-parser": "^13.0.1",
"@angular/cli": "^13.1.3", "@angular/cli": "^13.2.0",
"@angular/compiler-cli": "^13.1.2", "@angular/compiler-cli": "^13.2.0",
"@angular/language-service": "^13.1.2", "@angular/language-service": "^13.2.0",
"@types/jasmine": "^3.10.3", "@types/jasmine": "^3.10.3",
"@types/node": "^17.0.8", "@types/node": "^17.0.12",
"@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.9.1", "@typescript-eslint/parser": "^5.10.1",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsdoc": "^37.6.1", "eslint-plugin-jsdoc": "^37.7.0",
"eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-sort-keys-fix": "^1.1.2",
"jasmine": "^4.0.2", "jasmine": "^4.0.2",
"jasmine-core": "^4.0.0", "jasmine-core": "^4.0.0",
"jasmine-spec-reporter": "^7.0.0", "jasmine-spec-reporter": "^7.0.0",
"karma": "^6.3.11", "karma": "^6.3.12",
"karma-chrome-launcher": "^3.1.0", "karma-chrome-launcher": "^3.1.0",
"karma-cli": "^2.0.0", "karma-cli": "^2.0.0",
"karma-coverage-istanbul-reporter": "^3.0.3", "karma-coverage-istanbul-reporter": "^3.0.3",
@@ -86,7 +86,7 @@
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",
"typescript": "~4.5.4", "typescript": "~4.5.4",
"webpack": "^4.46.0" "webpack": "^5.67.0"
}, },
"peerDependencies": {} "peerDependencies": {}
} }

View File

@@ -62,6 +62,7 @@ import { ConfigTestingComponent } from './components/testing/config-testing/conf
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { EditorViewComponent } from './components/editor-view/editor-view.component'; import { EditorViewComponent } from './components/editor-view/editor-view.component';
import { HomeViewComponent } from './components/home-view/home-view.component'; import { HomeViewComponent } from './components/home-view/home-view.component';
import { ManagementViewComponent } from './components/management-view/management-view.component';
import { AdminViewComponent } from './components/admin-view/admin-view.component'; import { AdminViewComponent } from './components/admin-view/admin-view.component';
import { AppInitGuard, AuthGuard } from './guards'; import { AppInitGuard, AuthGuard } from './guards';
import { AppService } from './services/app.service'; import { AppService } from './services/app.service';
@@ -102,6 +103,7 @@ const DEV_PROVIDERS = [...PROD_PROVIDERS];
ErrorDialogComponent, ErrorDialogComponent,
EditorViewComponent, EditorViewComponent,
HomeViewComponent, HomeViewComponent,
ManagementViewComponent,
AdminViewComponent, AdminViewComponent,
NavBarComponent, NavBarComponent,
SideNavComponent, SideNavComponent,

View File

@@ -1,13 +1,6 @@
<mat-card> <mat-card>
<mat-card-title> <mat-card-title>
<div class="rule-title">{{serviceName | titlecase}} Admin Config</div> <div class="rule-title">{{serviceName | titlecase}} Admin Config</div>
<button
class="button"
mat-raised-button color="accent"
(click)="openApplicationDialog()"
>
Manage Applications
</button>
</mat-card-title> </mat-card-title>
<mat-card-subtitle> <mat-card-subtitle>
<p> <p>

View File

@@ -13,7 +13,6 @@ import { Router } from '@angular/router';
import { SubmitDialogComponent } from '../submit-dialog/submit-dialog.component'; import { SubmitDialogComponent } from '../submit-dialog/submit-dialog.component';
import { BlockUI, NgBlockUI } from 'ng-block-ui'; import { BlockUI, NgBlockUI } from 'ng-block-ui';
import { AppConfigService } from '@app/services/app-config.service'; import { AppConfigService } from '@app/services/app-config.service';
import { ApplicationDialogComponent } from '..';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -108,10 +107,6 @@ export class AdminComponent implements OnInit, OnDestroy {
this.form.updateValueAndValidity(); this.form.updateValueAndValidity();
} }
openApplicationDialog() {
this.dialog.open(ApplicationDialogComponent);
}
private updateAndWrapConfig(config: AdminConfig) { private updateAndWrapConfig(config: AdminConfig) {
//NOTE: in the form we are using wrapping config to handle optionals, unions //NOTE: in the form we are using wrapping config to handle optionals, unions
const configData = this.editorService.adminSchema.wrapConfig(config.configData); const configData = this.editorService.adminSchema.wrapConfig(config.configData);

View File

@@ -6,16 +6,15 @@ import { HomeComponent, PageNotFoundComponent } from '../../containers';
import { TestCaseHelpComponent } from '../testing/test-case-help/test-case-help.component'; import { TestCaseHelpComponent } from '../testing/test-case-help/test-case-help.component';
import { AppService } from '../../services/app.service'; import { AppService } from '../../services/app.service';
import { EditorServiceGuard } from '@app/guards'; import { AdminGuard, AuthGuard, ConfigEditGuard, EditorServiceGuard } from '@app/guards';
import { ConfigEditGuard } from '@app/guards';
import { AppConfigService } from '@app/services/app-config.service'; import { AppConfigService } from '@app/services/app-config.service';
import { EditorViewComponent } from '../editor-view/editor-view.component'; import { EditorViewComponent } from '../editor-view/editor-view.component';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AuthGuard } from '@app/guards';
import { AdminViewComponent } from '../admin-view/admin-view.component'; import { AdminViewComponent } from '../admin-view/admin-view.component';
import { AdminGuard } from '@app/guards';
import { UserRole } from '@app/model/config-model'; import { UserRole } from '@app/model/config-model';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { HomeViewComponent } from '../home-view/home-view.component';
import { ManagementViewComponent } from '../management-view/management-view.component';
@Component({ @Component({
template: '', template: '',
@@ -56,16 +55,20 @@ export class AppInitComponent implements OnInit, OnDestroy {
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'home', redirectTo: 'home',
}, },
{
path: 'home',
component: LandingPageComponent,
},
{ {
component: TestCaseHelpComponent, component: TestCaseHelpComponent,
path: 'help/testcase', path: 'help/testcase',
} },
]; ];
private homeRoute: Route = {
path: 'home',
component: LandingPageComponent,
children: [
{ path: '', component: HomeViewComponent },
],
}
constructor(private router: Router, constructor(private router: Router,
private appService: AppService, private appService: AppService,
private config: AppConfigService) { } private config: AppConfigService) { }
@@ -81,10 +84,14 @@ export class AppInitComponent implements OnInit, OnDestroy {
}); });
} }
ngOnDestroy() {
this.ngUnsubscribe.complete();
}
private loadRoutes() { private loadRoutes() {
const routes = this.appRoutes; const routes = this.appRoutes;
this.appService.serviceNames.forEach(s => { this.appService.serviceNames.forEach(s => {
let userRoles = this.appService.getUserServiceRoles(s); const userRoles = this.appService.getUserServiceRoles(s);
let childrenRoutes = []; let childrenRoutes = [];
if (userRoles.includes(UserRole.SERVICE_USER)) { if (userRoles.includes(UserRole.SERVICE_USER)) {
childrenRoutes = cloneDeep(this.configRoutes); childrenRoutes = cloneDeep(this.configRoutes);
@@ -97,14 +104,14 @@ export class AppInitComponent implements OnInit, OnDestroy {
}) })
}); });
if (this.appService.isAdminOfAnyService) {
this.homeRoute.children.push({ path: 'management', component: ManagementViewComponent })
}
routes.push(this.homeRoute);
routes.push({ routes.push({
component: PageNotFoundComponent, component: PageNotFoundComponent,
path: '**' path: '**'
}); });
this.router.resetConfig(routes); this.router.resetConfig(routes);
} }
ngOnDestroy() {
this.ngUnsubscribe.complete();
}
} }

View File

@@ -23,7 +23,7 @@
<ng-container matColumnDef="restart"> <ng-container matColumnDef="restart">
<th mat-header-cell *matHeaderCellDef></th> <th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row"> <td mat-cell *matCellDef="let row">
<button mat-raised-button color="primary" [disabled]="restartedApplications.includes(row.topology_name)" (click)="onRestartApplication(row.topology_name, restartedApplication)">Restart</button> <button mat-raised-button color="primary" [disabled]="restartedApplications.includes(row.topology_name) || disableAllRestart === true" (click)="onRestartApplication(row.service_name, row.topology_name, restartedApplication)">Restart</button>
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
@@ -31,6 +31,7 @@
</table> </table>
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-raised-button class="button-layout" color="accent" [disabled]="disableAllRestart" (click)="openInfoDialog('', confirmRestartAll)">RESTART ALL</button>
<button mat-raised-button class="button-layout" color="accent" (click)="onClickClose()">CLOSE</button> <button mat-raised-button class="button-layout" color="accent" (click)="onClickClose()">CLOSE</button>
</div> </div>
@@ -53,13 +54,21 @@
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-raised-button class="button-layout" color="mat-color($primary)" (click)="onClickCloseAttributes()">CLOSE</button> <button mat-raised-button class="button-layout" color="mat-color($primary)" (click)="onClickCloseInfo()">CLOSE</button>
</div> </div>
</ng-template> </ng-template>
<ng-template #restartedApplication let-data> <ng-template #restartedApplication let-data>
State of <b class="bold">{{data}}</b> has been changed, please <b class="bold">wait a few minutes</b> and check storm UI if the new state has been released State of <b class="bold">{{data}}</b> has been changed, please <b class="bold">wait a few minutes</b> and check storm UI if the new state has been released
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-raised-button class="button-layout" color="accent" (click)="onClickCloseAttributes()">OK</button> <button mat-raised-button class="button-layout" color="accent" (click)="onClickCloseInfo()">OK</button>
</div>
</ng-template>
<ng-template #confirmRestartAll let-data>
Are you sure you want to restart all applications?
<div mat-dialog-actions>
<button mat-raised-button class="button-layout" color="accent" (click)="restartAllApplications(restartedApplication)">RESTART ALL</button>
<button mat-raised-button class="button-layout" color="accent" (click)="onClickCloseInfo()">CANCEL</button>
</div> </div>
</ng-template> </ng-template>

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, TemplateRef } fr
import { MatDialog, MatDialogRef } from "@angular/material/dialog"; import { MatDialog, MatDialogRef } from "@angular/material/dialog";
import { MatTableDataSource } from "@angular/material/table"; import { MatTableDataSource } from "@angular/material/table";
import { Application, applicationManagerColumns, displayedApplicationManagerColumns } from "@app/model/config-model"; import { Application, applicationManagerColumns, displayedApplicationManagerColumns } from "@app/model/config-model";
import { EditorService } from "@app/services/editor.service"; import { AppService } from "@app/services/app.service";
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -11,19 +11,21 @@ import { EditorService } from "@app/services/editor.service";
templateUrl: 'application-dialog.component.html', templateUrl: 'application-dialog.component.html',
}) })
export class ApplicationDialogComponent { export class ApplicationDialogComponent {
dialogrefAttributes: MatDialogRef<any>; MAX_DIALOG_WIDTH = '800px';
dialogrefInfo: MatDialogRef<any>;
dataSource: MatTableDataSource<Application>; dataSource: MatTableDataSource<Application>;
columns = applicationManagerColumns; columns = applicationManagerColumns;
displayedColumns = displayedApplicationManagerColumns; displayedColumns = displayedApplicationManagerColumns;
restartedApplications: string[] = []; restartedApplications: string[] = [];
disableAllRestart = false;
constructor( constructor(
private dialogref: MatDialogRef<ApplicationDialogComponent>, private dialogref: MatDialogRef<ApplicationDialogComponent>,
private service: EditorService, private service: AppService,
private dialog: MatDialog, private dialog: MatDialog,
private cd: ChangeDetectorRef private cd: ChangeDetectorRef
) { ) {
this.service.configLoader.getApplications().subscribe(a => { this.service.getAllApplications().subscribe(a => {
this.createTable(a); this.createTable(a);
}) })
} }
@@ -32,30 +34,20 @@ export class ApplicationDialogComponent {
this.dialogref.close(); this.dialogref.close();
} }
onRestartApplication(applicationName: string, templateRef: TemplateRef<any>) { onRestartApplication(serviceName: string, applicationName: string, templateRef: TemplateRef<any>) {
this.service.configLoader.restartApplication(applicationName).subscribe(a => { this.service.restartApplication(serviceName, applicationName).subscribe(a => {
this.createTable(a); this.createTable(a);
this.restartedApplications.push(applicationName); this.restartedApplications.push(applicationName);
}) });
this.dialogrefAttributes = this.dialog.open( this.openInfoDialog(applicationName, templateRef);
templateRef,
{
data: applicationName,
maxWidth: '800px',
});
} }
onViewAttributes(attributes: string[], templateRef: TemplateRef<any>) { onViewAttributes(attributes: string[], templateRef: TemplateRef<any>) {
this.dialogrefAttributes = this.dialog.open( this.openInfoDialog(attributes.map(a => JSON.parse(atob(a))), templateRef);
templateRef,
{
data: attributes.map(a => JSON.parse(atob(a))),
});
} }
onClickCloseAttributes() { onClickCloseInfo() {
this.dialogrefAttributes.close(); this.dialogrefInfo.close();
} }
applyFilter(event: Event) { applyFilter(event: Event) {
@@ -63,6 +55,25 @@ export class ApplicationDialogComponent {
this.dataSource.filter = filterValue.trim().toLowerCase(); this.dataSource.filter = filterValue.trim().toLowerCase();
} }
restartAllApplications(templateRef: TemplateRef<any>) {
this.service.restartAllApplications().subscribe((apps: Application[]) => {
this.createTable(apps);
this.onClickCloseInfo();
this.disableAllRestart = true;
this.cd.markForCheck();
this.openInfoDialog("all applications", templateRef);
})
}
openInfoDialog(data: any, templateRef: TemplateRef<any>) {
this.dialogrefInfo = this.dialog.open(
templateRef,
{
data,
maxWidth: this.MAX_DIALOG_WIDTH,
});
}
private createTable(a: Application[]) { private createTable(a: Application[]) {
const filter = this.dataSource?.filter; const filter = this.dataSource?.filter;
this.dataSource = new MatTableDataSource(a); this.dataSource = new MatTableDataSource(a);

View File

@@ -29,7 +29,7 @@
<mat-expansion-panel-header>Explore Siembol</mat-expansion-panel-header> <mat-expansion-panel-header>Explore Siembol</mat-expansion-panel-header>
<mat-grid-list cols="4" rowHeight="2:1"> <mat-grid-list cols="4" rowHeight="2:1">
<mat-grid-tile *ngFor="let link of homeHelpLinks"> <mat-grid-tile *ngFor="let link of homeHelpLinks">
<button class="with-icon" mat-button (click)="openLink(link.link)"> <button [matTooltip]="link.link" class="with-icon" mat-button (click)="openLink(link.link)">
<mat-icon>{{link.icon}}</mat-icon> <mat-icon>{{link.icon}}</mat-icon>
{{link.title}} {{link.title}}
</button> </button>

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { UrlHistoryService } from '@app/services/url-history.service'; import { UrlHistoryService } from '@app/services/url-history.service';
import { AppConfigService } from '@app/services/app-config.service'; import { AppConfigService } from '@app/services/app-config.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { HomeHelpLink } from '@app/model/app-config'; import { HelpLink } from '@app/model/app-config';
import { ServiceInfo } from '@app/model/config-model'; import { ServiceInfo } from '@app/model/config-model';
import { AppService } from '@app/services/app.service'; import { AppService } from '@app/services/app.service';
import { parseUrl } from '@app/commons/helper-functions' import { parseUrl } from '@app/commons/helper-functions'
@@ -16,7 +16,7 @@ import { parseUrl } from '@app/commons/helper-functions'
export class HomeViewComponent implements OnInit { export class HomeViewComponent implements OnInit {
history: string[]; history: string[];
root: string; root: string;
homeHelpLinks: HomeHelpLink[]; homeHelpLinks: HelpLink[];
userServices: ServiceInfo[]; userServices: ServiceInfo[];
constructor(private historyService: UrlHistoryService, constructor(private historyService: UrlHistoryService,

View File

@@ -0,0 +1,23 @@
<div class="router-holder" style="height:100vh">
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>Management Links</mat-expansion-panel-header>
<mat-grid-list cols="4" rowHeight="2:1">
<mat-grid-tile *ngFor="let link of managementLinks">
<button [matTooltip]="link.link" class="with-icon" mat-button (click)="openLink(link.link)">
<mat-icon>{{link.icon}}</mat-icon>
{{link.title}}
</button>
</mat-grid-tile>
</mat-grid-list>
</mat-expansion-panel>
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>Actions</mat-expansion-panel-header>
<mat-grid-list cols="2" rowHeight="2:1">
<mat-grid-tile>
<button mat-button (click)="openApplicationDialog()">
<mat-icon>apps</mat-icon> Manage Appplications
</button>
</mat-grid-tile>
</mat-grid-list>
</mat-expansion-panel>
</div>

View File

@@ -0,0 +1,13 @@
.mat-expansion-panel {
margin-top: 30px;
margin-bottom: 30px;
max-width: 70vw;
margin-right: auto;
margin-left: auto;
}
::ng-deep .with-icon .mat-button-wrapper {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
import { MatDialog, MatDialogRef } from "@angular/material/dialog";
import { HelpLink } from "@app/model/app-config";
import { AppConfigService } from "@app/services/app-config.service";
import { ApplicationDialogComponent } from "..";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-management-view',
styleUrls: ['./management-view.component.scss'],
templateUrl: './management-view.component.html',
})
export class ManagementViewComponent implements OnInit {
managementLinks: HelpLink[];
dialogref: MatDialogRef<any>;
constructor(
private appConfigService: AppConfigService,
private dialog: MatDialog
) {}
ngOnInit() {
this.managementLinks = this.appConfigService.managementLinks;
}
openLink(link: string) {
window.open(link, "_blank");
}
openApplicationDialog() {
this.dialog.open(ApplicationDialogComponent);
}
}

View File

@@ -17,6 +17,9 @@
</button> </button>
</mat-menu> </mat-menu>
</div> </div>
<div *ngIf="isManagement">
<h1>Management</h1>
</div>
</div> </div>
<div> <div>
<div *ngIf="!isHome"> <div *ngIf="!isHome">
@@ -67,6 +70,15 @@
{{repoNames.admin_config_store_directory_name}} {{repoNames.admin_config_store_directory_name}}
</a> </a>
</mat-menu> </mat-menu>
<button
*ngIf="isAdminOfAnyService"
mat-icon-button
class="right-element"
[routerLink]="['/home/management']"
[matTooltip]="'Management'"
>
<mat-icon>settings_application</mat-icon>
</button>
<button <button
mat-icon-button mat-icon-button
class="right-element" class="right-element"

View File

@@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit} from '@angular/core';
import { AppConfigService } from '@app/services/app-config.service'; import { AppConfigService } from '@app/services/app-config.service';
import { Observable } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { BuildInfoDialogComponent } from '../build-info-dialog/build-info-dialog.component'; import { BuildInfoDialogComponent } from '../build-info-dialog/build-info-dialog.component';
import { EditorService } from '../../services/editor.service'; import { EditorService } from '../../services/editor.service';
import { AppService } from '../../services/app.service'; import { AppService } from '../../services/app.service';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, NavigationEnd } from '@angular/router';
import { UserRole, RepositoryLinks, repoNames } from '@app/model/config-model'; import { UserRole, RepositoryLinks, repoNames } from '@app/model/config-model';
import { startWith, takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,7 +15,8 @@ import { UserRole, RepositoryLinks, repoNames } from '@app/model/config-model';
styleUrls: ['./nav-bar.component.scss'], styleUrls: ['./nav-bar.component.scss'],
templateUrl: './nav-bar.component.html', templateUrl: './nav-bar.component.html',
}) })
export class NavBarComponent implements OnInit { export class NavBarComponent implements OnInit, OnDestroy {
ngUnsubscribe = new Subject();
user: string; user: string;
userRoles: string[]; userRoles: string[];
serviceName$: Observable<string>; serviceName$: Observable<string>;
@@ -23,14 +25,15 @@ export class NavBarComponent implements OnInit {
environment: string; environment: string;
isAdminChecked: boolean; isAdminChecked: boolean;
isHome: boolean; isHome: boolean;
isManagement: boolean;
repositoryLinks: RepositoryLinks; repositoryLinks: RepositoryLinks;
isAdminOfAnyService: boolean;
readonly repoNames = repoNames; readonly repoNames = repoNames;
constructor(private config: AppConfigService, constructor(private config: AppConfigService,
private appService: AppService, private appService: AppService,
private editorService: EditorService, private editorService: EditorService,
private dialog: MatDialog, private dialog: MatDialog,
private activeRoute: ActivatedRoute,
private router: Router) { private router: Router) {
this.user = this.appService.user; this.user = this.appService.user;
this.serviceName$ = this.editorService.serviceName$; this.serviceName$ = this.editorService.serviceName$;
@@ -40,7 +43,8 @@ export class NavBarComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.serviceName$.subscribe(service => { this.isAdminOfAnyService = this.appService.isAdminOfAnyService;
this.serviceName$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(service => {
if (service) { if (service) {
this.userRoles = this.appService.getUserServiceRoles(service); this.userRoles = this.appService.getUserServiceRoles(service);
this.repositoryLinks = this.appService.getServiceRepositoryLink(service); this.repositoryLinks = this.appService.getServiceRepositoryLink(service);
@@ -48,8 +52,15 @@ export class NavBarComponent implements OnInit {
this.serviceName = service; this.serviceName = service;
}); });
this.activeRoute.url.subscribe(url => { this.router.events
this.isHome = this.config.isHomePath('/' + url[0].path); .pipe(
startWith(this.router),
takeUntil(this.ngUnsubscribe))
.subscribe(event => {
if (event instanceof NavigationEnd || event instanceof Router){
this.isHome = this.config.isHomePath(event.url);
this.isManagement = this.config.isManagementPath(event.url);
}
}); });
} }
@@ -71,4 +82,9 @@ export class NavBarComponent implements OnInit {
} }
return path; return path;
} }
ngOnDestroy() {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
} }

View File

@@ -57,6 +57,8 @@
</div> </div>
</mat-sidenav> </mat-sidenav>
<mat-sidenav-content> <mat-sidenav-content>
<re-home-view></re-home-view> <div class="router-holder">
<router-outlet></router-outlet>
</div>
</mat-sidenav-content> </mat-sidenav-content>
</mat-sidenav-container> </mat-sidenav-container>

View File

@@ -19,13 +19,14 @@ export interface AppConfig {
aboutApp: BuildInfo; aboutApp: BuildInfo;
authType: AuthenticationType; authType: AuthenticationType;
authAttributes: Oauth2Attributes | any; authAttributes: Oauth2Attributes | any;
homeHelpLinks?: HomeHelpLink[]; homeHelpLinks?: HelpLink[];
managementLinks?: HelpLink[];
historyMaxSize?: number; historyMaxSize?: number;
blockingTimeout?: number; blockingTimeout?: number;
useImporters?: boolean; useImporters?: boolean;
} }
export interface HomeHelpLink { export interface HelpLink {
title: string; title: string;
icon: string; icon: string;
link: string; link: string;
@@ -36,3 +37,5 @@ export interface Oauth2Attributes {
expiresIntervalMinimum: number; expiresIntervalMinimum: number;
oidcSettings: UserSettings; oidcSettings: UserSettings;
} }
export const HOME_REGEX = new RegExp('^\/($|home(\/|$))');

View File

@@ -208,8 +208,13 @@ export interface Application {
export const applicationManagerColumns = [ export const applicationManagerColumns = [
{ {
columnDef: 'name', columnDef: 'service_name',
header: 'Name', header: 'Service Name',
cell: (t: Application) => `${t.service_name}`,
},
{
columnDef: 'application_name',
header: 'Application Name',
cell: (t: Application) => `${t.topology_name}`, cell: (t: Application) => `${t.topology_name}`,
}, },
{ {
@@ -224,4 +229,4 @@ export const applicationManagerColumns = [
}, },
]; ];
export const displayedApplicationManagerColumns = ["name", "id", "image", "attributes", "restart"]; export const displayedApplicationManagerColumns = ["service_name", "application_name", "id", "image", "attributes", "restart"];

View File

@@ -0,0 +1,32 @@
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { TestBed } from "@angular/core/testing";
import { AppConfigService } from "./app-config.service";
describe('AppConfigService', () => {
let service: AppConfigService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AppConfigService],
});
service = TestBed.inject(AppConfigService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('isHomePath', () => {
it('should be home path', () => {
expect(service.isHomePath('/')).toBeTrue();
expect(service.isHomePath('/home')).toBeTrue();
expect(service.isHomePath('/home/management')).toBeTrue();
})
it('should not be home path', () => {
expect(service.isHomePath('/test')).toBeFalse();
expect(service.isHomePath('/homee')).toBeFalse();
expect(service.isHomePath('/test/management')).toBeFalse();
})
})
});

View File

@@ -11,7 +11,7 @@ import {
} from './authentication.service'; } from './authentication.service';
import { Oauth2AuthenticationService } from '@app/services/oauth2-authentication.service'; import { Oauth2AuthenticationService } from '@app/services/oauth2-authentication.service';
import { AppConfig, AuthenticationType, BuildInfo } from '../model'; import { AppConfig, AuthenticationType, BuildInfo } from '../model';
import { HomeHelpLink } from '@app/model/app-config'; import { HelpLink, HOME_REGEX } from '@app/model/app-config';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -44,7 +44,14 @@ export class AppConfigService {
} }
isHomePath(path: string): boolean { isHomePath(path: string): boolean {
if (path === '/home' || path === '/') { if (HOME_REGEX.test(path)) {
return true;
}
return false;
}
isManagementPath(path: string): boolean {
if (path === '/home/management') {
return true; return true;
} }
return false; return false;
@@ -90,10 +97,14 @@ export class AppConfigService {
return this._authenticationService; return this._authenticationService;
} }
get homeHelpLinks(): HomeHelpLink[] { get homeHelpLinks(): HelpLink[] {
return this._config.homeHelpLinks; return this._config.homeHelpLinks;
} }
get managementLinks(): HelpLink[] {
return this._config.managementLinks;
}
get historyMaxSize(): number { get historyMaxSize(): number {
return this._config.historyMaxSize ? this._config.historyMaxSize : 5; return this._config.historyMaxSize ? this._config.historyMaxSize : 5;
} }

View File

@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { AppConfigService } from '@app/services/app-config.service'; import { AppConfigService } from '@app/services/app-config.service';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { mockAppContext, mockUserServicesMap, mockAppContextWithTestSchema } from 'testing/appContext'; import { mockAppContext, mockAppContextNoAdmin, mockUserServicesMap } from 'testing/appContext';
import { mockUserInfo } from 'testing/user'; import { mockUserInfo } from 'testing/user';
import { mockUiMetadataMap } from 'testing/uiMetadataMap'; import { mockUiMetadataMap } from 'testing/uiMetadataMap';
import { RepositoryLinks, UserRole } from '@app/model/config-model'; import { RepositoryLinks, UserRole } from '@app/model/config-model';
@@ -10,6 +10,46 @@ import { mockTestCasesSchema } from 'testing/testCasesSchema';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
const mockTopology1 =
{
image: "test-image-alert",
attributes: ["test"],
topology_name: "myalert1",
topology_id: "123",
service_name: "myalert",
}
const mockTopology2 =
{
image: "test-image-parsers",
attributes: ["test"],
topology_name: "myparsingapp1",
topology_id: "456",
service_name: "myparsingapp",
}
const mockTopology3 =
{
image: "test-image-parsers",
attributes: ["test"],
topology_name: "myparsingapp2",
topology_id: "789",
service_name: "myparsingapp",
}
const mockTopologies1 = {
topologies: [
mockTopology1,
],
}
const mockTopologies2 = {
topologies: [
mockTopology2,
mockTopology3,
],
}
const mockRepositories1 = { const mockRepositories1 = {
"rules_repositories": { "rules_repositories": {
"rule_store_url": "https://github.com/test-siembol-config.git", "rule_store_url": "https://github.com/test-siembol-config.git",
@@ -79,9 +119,9 @@ describe('AppService', () => {
it('should create app context', done => { it('should create app context', done => {
spyOn<any>(service, 'loadUserInfo').and.returnValue(of(cloneDeep(mockAppContext))); spyOn<any>(service, 'loadUserInfo').and.returnValue(of(cloneDeep(mockAppContext)));
spyOn<any>(service, 'loadTestCaseSchema').and.returnValue(of(mockTestCasesSchema)); spyOn<any>(service, 'loadTestCaseSchema').and.returnValue(of(mockTestCasesSchema));
service.createAppContext().subscribe(context => { service.createAppContext().subscribe(context => {
expect(context.repositoryLinks).toEqual(expectedAllRepositoriesLinks); expect(context.repositoryLinks).toEqual(expectedAllRepositoriesLinks);
expect(context.isAdminOfAnyService).toEqual(true);
done(); done();
}); });
@@ -92,5 +132,47 @@ describe('AppService', () => {
const req2 = httpTestingController.expectOne('/api/v1/myparserconfig/configstore/repositories'); const req2 = httpTestingController.expectOne('/api/v1/myparserconfig/configstore/repositories');
expect(req2.request.method).toEqual('GET'); expect(req2.request.method).toEqual('GET');
req2.flush(mockRepositories2); req2.flush(mockRepositories2);
});
it('should get all applications: admin of one', done => {
service.setAppContext(mockAppContext);
service.getAllApplications().subscribe(apps => {
expect(apps).toEqual([mockTopology1]);
done()
})
const req1 = httpTestingController.expectOne('/api/v1/myalert/topologies');
expect(req1.request.method).toEqual('GET');
req1.flush(mockTopologies1);
})
it('should get all applications: admin of both', done => {
const mockAppContext2 = cloneDeep(mockAppContext);
mockAppContext2.userServices.push({
name: 'myparsingapp',
type: 'parsingapp',
user_roles: [
UserRole.SERVICE_USER,
UserRole.SERVICE_ADMIN,
],
})
service.setAppContext(mockAppContext2);
service.getAllApplications().subscribe(apps => {
expect(apps).toEqual([mockTopology1, mockTopology2, mockTopology3]);
done()
})
const req1 = httpTestingController.expectOne('/api/v1/myalert/topologies');
expect(req1.request.method).toEqual('GET');
req1.flush(mockTopologies1);
const req2 = httpTestingController.expectOne('/api/v1/myparsingapp/topologies');
expect(req2.request.method).toEqual('GET');
req2.flush(mockTopologies2);
})
it('should not be admin user', () => {
service.setAppContext(mockAppContextNoAdmin);
expect((service as any).isUserAdminOfAnyService(mockAppContextNoAdmin.userServices)).toBeFalse();
}) })
}); });

View File

@@ -1,12 +1,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AppConfigService } from '@app/services/app-config.service'; import { AppConfigService } from '@app/services/app-config.service';
import { ServiceInfo, RepositoryLinks, RepositoryLinksWrapper, UserInfo, SchemaInfo, UserRole } from '@app/model/config-model'; import { ServiceInfo, RepositoryLinks, RepositoryLinksWrapper, UserInfo, UserRole, SchemaInfo, Application, applications } from '@app/model/config-model';
import { Observable, throwError, BehaviorSubject, forkJoin, of } from 'rxjs'; import { Observable, throwError, BehaviorSubject, forkJoin, of } from 'rxjs';
import { JSONSchema7 } from 'json-schema'; import { JSONSchema7 } from 'json-schema';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { UiMetadata } from '@app/model/ui-metadata-map'; import { UiMetadata } from '@app/model/ui-metadata-map';
import 'rxjs/add/observable/forkJoin'; import 'rxjs/add/observable/forkJoin';
import { mergeMap } from 'rxjs/operators'; import { map, mergeMap } from 'rxjs/operators';
export class AppContext { export class AppContext {
user: string; user: string;
@@ -14,6 +14,7 @@ export class AppContext {
userServicesMap: Map<string, ServiceInfo>; userServicesMap: Map<string, ServiceInfo>;
testCaseSchema: JSONSchema7; testCaseSchema: JSONSchema7;
repositoryLinks: { [name: string]: RepositoryLinks }; repositoryLinks: { [name: string]: RepositoryLinks };
isAdminOfAnyService: boolean;
get serviceNames() { get serviceNames() {
return Array.from(this.userServicesMap.keys()).sort(); return Array.from(this.userServicesMap.keys()).sort();
} }
@@ -51,6 +52,10 @@ export class AppService {
return this.appContext.repositoryLinks; return this.appContext.repositoryLinks;
} }
get isAdminOfAnyService() {
return this.appContext.isAdminOfAnyService;
}
constructor(private config: AppConfigService, private http: HttpClient) {} constructor(private config: AppConfigService, private http: HttpClient) {}
setAppContext(appContext: AppContext): boolean { setAppContext(appContext: AppContext): boolean {
@@ -72,6 +77,7 @@ export class AppService {
if (appContext && testCaseSchema && repositoryLinks) { if (appContext && testCaseSchema && repositoryLinks) {
appContext.testCaseSchema = testCaseSchema; appContext.testCaseSchema = testCaseSchema;
appContext.repositoryLinks = repositoryLinks; appContext.repositoryLinks = repositoryLinks;
appContext.isAdminOfAnyService = this.isUserAdminOfAnyService(appContext.userServices);
return appContext; return appContext;
} }
throwError('Can not load application context'); throwError('Can not load application context');
@@ -96,10 +102,47 @@ export class AppService {
return this.appContext.userServicesMap.get(serviceName).user_roles; return this.appContext.userServicesMap.get(serviceName).user_roles;
} }
restartApplication(serviceName: string, application: string): Observable<Application[]> {
return this.http.post<applications>(
`${this.config.serviceRoot}api/v1/${serviceName}/topologies/${application}/restart`,
null
).pipe(map(result => result.topologies));
}
restartAllApplications(): Observable<Application[]> {
return this.http.post<applications>(
`${this.config.serviceRoot}api/v1/topologies/restart`,
null
).pipe(map(result => result.topologies));
}
getAllApplications(): Observable<Application[]> {
return forkJoin(
this.userServices
.filter(userService => userService.user_roles.includes(UserRole.SERVICE_ADMIN))
.map(userService => this.getServiceApplications(userService.name))
).pipe(map(topologies => topologies.flat()));
}
getServiceRepositoryLink(serviceName: string): RepositoryLinks { getServiceRepositoryLink(serviceName: string): RepositoryLinks {
return this.appContext.repositoryLinks[serviceName]; return this.appContext.repositoryLinks[serviceName];
} }
private isUserAdminOfAnyService(userServices: ServiceInfo[]): boolean {
for (const userService of userServices) {
if (userService.user_roles.includes(UserRole.SERVICE_ADMIN)) {
return true;
}
}
return false;
}
private getServiceApplications(serviceName: string): Observable<Application[]> {
return this.http.get<applications>(
`${this.config.serviceRoot}api/v1/${serviceName}/topologies`
).pipe(map(result => result.topologies));
}
private getAllRepositoryLinks(userServices: ServiceInfo[]): Observable<{ [name: string]: RepositoryLinks }> { private getAllRepositoryLinks(userServices: ServiceInfo[]): Observable<{ [name: string]: RepositoryLinks }> {
return forkJoin(userServices.map(x => this.getRepositoryLinks(x.name))) return forkJoin(userServices.map(x => this.getRepositoryLinks(x.name)))
.map((links: RepositoryLinks[]) => { .map((links: RepositoryLinks[]) => {

View File

@@ -21,8 +21,6 @@ import {
Importers, Importers,
ImportedConfig, ImportedConfig,
ConfigToImport, ConfigToImport,
applications,
Application,
} from '@model/config-model'; } from '@model/config-model';
import { TestCase, TestCaseMap, TestCaseResult, TestCaseWrapper } from '@model/test-case'; import { TestCase, TestCaseMap, TestCaseResult, TestCaseWrapper } from '@model/test-case';
import { ADMIN_VERSION_FIELD_NAME, UiMetadata } from '@model/ui-metadata-map'; import { ADMIN_VERSION_FIELD_NAME, UiMetadata } from '@model/ui-metadata-map';
@@ -414,19 +412,6 @@ export class ConfigLoaderService {
.pipe(map(result => result)); .pipe(map(result => result));
} }
getApplications(): Observable<Application[]> {
return this.http.get<applications>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/topologies`
).pipe(map(result => result.topologies));
}
restartApplication(application: string): Observable<Application[]> {
return this.http.post<applications>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/topologies/${application}/restart`,
null
).pipe(map(result => result.topologies.filter(t => t.service_name === this.serviceName)));
}
private testCaseFilesToMap(files: any[]): TestCaseMap { private testCaseFilesToMap(files: any[]): TestCaseMap {
const testCaseMap: TestCaseMap = {}; const testCaseMap: TestCaseMap = {};
if (files && files.length > 0) { if (files && files.length > 0) {

View File

@@ -13,6 +13,15 @@ export const mockUserInfoAlert: ServiceInfo =
], ],
}; };
export const mockUserInfoAlertNoAdmin: ServiceInfo =
{
name: 'myalert',
type: 'alert',
user_roles: [
UserRole.SERVICE_USER,
],
};
export const mockUserInfoParser: ServiceInfo = export const mockUserInfoParser: ServiceInfo =
{ {
name: 'myparserconfig', name: 'myparserconfig',
@@ -26,10 +35,19 @@ export const mockUserServicesMap = new Map();
mockUserServicesMap.set('myalert', mockUserInfoAlert); mockUserServicesMap.set('myalert', mockUserInfoAlert);
mockUserServicesMap.set('myparserconfig', mockUserInfoParser); mockUserServicesMap.set('myparserconfig', mockUserInfoParser);
export const mockUserServicesMapNoAdmin = new Map();
mockUserServicesMapNoAdmin.set('myalert', mockUserInfoAlertNoAdmin);
mockUserServicesMapNoAdmin.set('myparserconfig', mockUserInfoParser);
export const mockAppContext = new AppContext(); export const mockAppContext = new AppContext();
mockAppContext.user = 'siembol'; mockAppContext.user = 'siembol';
mockAppContext.userServices= [mockUserInfoAlert, mockUserInfoParser]; mockAppContext.userServices= [mockUserInfoAlert, mockUserInfoParser];
mockAppContext.userServicesMap = mockUserServicesMap; mockAppContext.userServicesMap = mockUserServicesMap;
export const mockAppContextNoAdmin = new AppContext();
mockAppContextNoAdmin.user = 'siembol';
mockAppContextNoAdmin.userServices= [mockUserInfoAlertNoAdmin, mockUserInfoParser];
mockAppContextNoAdmin.userServicesMap = mockUserServicesMapNoAdmin;
export const mockAppContextWithTestSchema = cloneDeep(mockAppContext); export const mockAppContextWithTestSchema = cloneDeep(mockAppContext);
mockAppContextWithTestSchema.testCaseSchema = mockTestCasesSchema; mockAppContextWithTestSchema.testCaseSchema = mockTestCasesSchema;

View File

@@ -22,5 +22,17 @@
"title": "Discussions", "title": "Discussions",
"link": "https://github.com/G-Research/siembol/discussions" "link": "https://github.com/G-Research/siembol/discussions"
} }
],
"managementLinks": [
{
"title": "Storm UI",
"icon": "apps",
"link": "storm.siembol.local"
},
{
"title": "Enrichment Tables",
"icon": "table_chart",
"link": "enrichment.siembol.local"
}
] ]
} }

View File

@@ -1,5 +1,7 @@
# How to add links to the siembol ui home page # How to add links to the siembol ui home page or the management page
The siembol home page has an 'Explore Siembol' section at the button of its home page, as can be seen in the screenshot below. It is used for quick access to useful resources such as documentation, ticket tracking systems etc. By default there is a link to the documentation and to the issues page on the git repo.
## Siembol home page
The Siembol home page has an 'Explore Siembol' section at the button of its home page, as can be seen in the screenshot below. It is used for quick access to useful resources such as documentation, ticket tracking systems etc. By default there is a link to the documentation and to the issues page on the git repo.
![image](../screenshots/home_page.png) ![image](../screenshots/home_page.png)
@@ -30,3 +32,9 @@ To add a new link you need three things:
- the url to which the user will be redirected on clicking the link - the url to which the user will be redirected on clicking the link
- the icon to be displayed; this has to be the name of a material icon (you can find them all here: https://material.io/) - the icon to be displayed; this has to be the name of a material icon (you can find them all here: https://material.io/)
- the title to display below the icon - the title to display below the icon
## Siembol management page
The Siembol [management page](./how_to_use_the_management_page.md), which is only accessible by admins of any service, has a 'Management Links' section at the top of the page, similar to the links in the home page. This can be used to add links useful to admins.
Links are added by the user in the `ui-config.json` file in the same way as for home links, but the key is "managementLinks".

View File

@@ -1,14 +1,15 @@
# How to manage applications # How to manage applications
Admin users can manage applications from the admin panel by clicking on the top right `Manage Applications` button (see screenshot below). Admin users can manage applications from the actions in the [management page](./how_to_use_the_management_page.md) by clicking on the `Manage Applications` button.
<img src="../screenshots/admin_editor.png" alt="drawing"/> This opens up a dialog similar to the own in the screenshot below, showing the running applications for all services the user is an admin for with the service name, application name, id, image and attributes.
A single application or all applications can be restarted from here. Please wait a few minutes after restarting applications and check storm UI if the new state has been released
This opens up a dialog similar to the own in the screenshot below, showing all the running applications for the service with their name, id, image and attributes.
Any application can also be restarted from there.
<img src="../screenshots/applications_manager.png" alt="drawing"/> <img src="../screenshots/applications_manager.png" alt="drawing"/>
If there are multiple applications it is possible to search through them using a filter, like in the screenshot below. If there are multiple applications it is possible to search through them using a filter, like in the screenshot below.
<img src="../screenshots/applications_manager_multiple.png" alt="drawing"/> > **_note:_** The `Restart All` button will always restart ALL applications even if there is a filter.
<img src="../screenshots/applications_manager_filter.png" alt="drawing"/>

View File

@@ -0,0 +1,11 @@
# How to use the management page
The management page can only be accessed by an admin of any service. It has two sections: one with management links and one with various actions. It can be accessed by clicking on the cog on the top right of the navigation bar.
<img src="../screenshots/management_page.png" alt="drawing"/>
## Links
The management links are links useful to only admins. To add management links see [here](./how_to_add_links_to_siembol_ui_home_page.md).
## Actions
Currently there is only one admin action: managing the running applications. This opens up a dialog which is explained in detail [here](./how_to_manage_applications.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB