Merge pull request #1462 from Orange-OpenSource/master-3.0

PR: Add resources pools management + ACE/ACL management
This commit is contained in:
Jeremy Grossmann 2023-09-28 18:44:43 +10:00 committed by GitHub
commit c517e98bb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2242 additions and 1 deletions

View File

@ -69,6 +69,10 @@ import { GroupResolver } from "./resolvers/group.resolver";
import { GroupRoleResolver } from "./resolvers/group-role.resolver";
import { RoleDetailComponent } from "./components/role-management/role-detail/role-detail.component";
import { RoleDetailResolver } from "./resolvers/role-detail.resolver";
import {AceManagementComponent} from "@components/ace-management/ace-management.component";
import {ResourcePoolsManagementComponent} from "@components/resource-pools-management/resource-pools-management.component";
import {ResourcePoolDetailsComponent} from "@components/resource-pool-details/resource-pool-details.component";
import {ResourcePoolsResolver} from "@resolvers/resource-pools.resolver";
const routes: Routes = [
{
@ -99,6 +103,15 @@ const routes: Routes = [
groups: UserGroupsResolver,
controller: ControllerResolve},
},
{
path: 'controller/:controller_id/management/resourcePools/:pool_id',
component: ResourcePoolDetailsComponent,
canActivate: [LoginGuard],
resolve: {
pool: ResourcePoolsResolver,
controller: ControllerResolve
}
},
{ path: 'installed-software', component: InstalledSoftwareComponent },
{ path: 'controller/:controller_id/systemstatus', component: SystemStatusComponent, canActivate: [LoginGuard] },
@ -231,6 +244,14 @@ const routes: Routes = [
{
path: 'roles',
component: RoleManagementComponent
},
{
path: "resourcePools",
component: ResourcePoolsManagementComponent
},
{
path: 'aces',
component: AceManagementComponent
}
]
},

View File

@ -309,8 +309,22 @@ import { ExportPortableProjectComponent } from './components/export-portable-pro
import { NodesMenuConfirmationDialogComponent } from './components/project-map/nodes-menu/nodes-menu-confirmation-dialog/nodes-menu-confirmation-dialog.component';
import { ConfirmationDeleteAllProjectsComponent } from './components/projects/confirmation-delete-all-projects/confirmation-delete-all-projects.component';
import { ProjectMapLockConfirmationDialogComponent } from './components/project-map/project-map-menu/project-map-lock-confirmation-dialog/project-map-lock-confirmation-dialog.component';
import {AceManagementComponent} from "@components/ace-management/ace-management.component";
import { AddAceDialogComponent } from './components/ace-management/add-ace-dialog/add-ace-dialog.component';
import { AutocompleteComponent } from './components/ace-management/add-ace-dialog/autocomplete/autocomplete.component';
import { DeleteAceDialogComponent } from './components/ace-management/delete-ace-dialog/delete-ace-dialog.component';
import { AceFilterPipe } from './filters/ace-filter.pipe';
import {CdkAccordionModule} from "@angular/cdk/accordion";
import {CdkTreeModule} from "@angular/cdk/tree";
import { PrivilegeComponent } from './components/role-management/role-detail/privilege/privilege.component';
import { GroupPrivilegesPipe } from './components/role-management/role-detail/privilege/group-privileges.pipe';
import { ResourcePoolsManagementComponent } from './components/resource-pools-management/resource-pools-management.component';
import { AddResourcePoolDialogComponent } from './components/resource-pools-management/add-resource-pool-dialog/add-resource-pool-dialog.component';
import { DeleteResourcePoolComponent } from './components/resource-pools-management/delete-resource-pool/delete-resource-pool.component';
import { ResourcePoolsFilterPipe } from './components/resource-pools-management/resource-pools-filter.pipe';
import { ResourcePoolDetailsComponent } from './components/resource-pool-details/resource-pool-details.component';
import { DeleteResourceConfirmationDialogComponent } from './components/resource-pool-details/delete-resource-confirmation-dialog/delete-resource-confirmation-dialog.component';
@NgModule({
declarations: [
@ -533,8 +547,19 @@ import { GroupPrivilegesPipe } from './components/role-management/role-detail/pr
NodesMenuConfirmationDialogComponent,
ConfirmationDeleteAllProjectsComponent,
ProjectMapLockConfirmationDialogComponent,
AceManagementComponent,
AddAceDialogComponent,
AutocompleteComponent,
DeleteAceDialogComponent,
AceFilterPipe,
PrivilegeComponent,
GroupPrivilegesPipe,
ResourcePoolsManagementComponent,
AddResourcePoolDialogComponent,
DeleteResourcePoolComponent,
ResourcePoolsFilterPipe,
ResourcePoolDetailsComponent,
DeleteResourceConfirmationDialogComponent,
],
imports: [
BrowserModule,
@ -560,6 +585,8 @@ import { GroupPrivilegesPipe } from './components/role-management/role-detail/pr
MatSlideToggleModule,
MatCheckboxModule,
MatAutocompleteModule,
CdkAccordionModule,
CdkTreeModule,
],
providers: [
SettingsService,

View File

@ -0,0 +1,98 @@
<div class="content" *ngIf="isReady; else loading">
<div class="default-header">
<div class="row">
<h1 class="col">ACEs management</h1>
<button class="col" mat-raised-button color="primary" (click)="deleteMultiple()" class="add-ace-button" [disabled]="selection.selected.length == 0">
Delete selected ACEs
</button>
<button class="col" mat-raised-button color="primary" (click)="addACE()" class="add-ace-button">
Add ACE
</button>
</div>
</div>
<form>
<mat-form-field class="full-width">
<input matInput placeholder="Search by path, user/group or role" [(ngModel)]="searchText"
[ngModelOptions]="{ standalone: true }"/>
</mat-form-field>
</form>
<div class="default-content">
<table mat-table [dataSource]="dataSource | aceFilter: searchText:endpoints " class="mat-elevation-z8" matSort #acesSort="matSort">
<ng-container matColumnDef="select" >
<th mat-header-cell *matHeaderCellDef class="small-col">
<mat-checkbox (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row" class="small-col">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="path">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Path</th>
<td mat-cell *matCellDef="let element"> {{getNameByUuidFromEndpoint(element.path)}} </td>
</ng-container>
<ng-container matColumnDef="user/group">
<th mat-header-cell *matHeaderCellDef mat-sort-header>User/Group</th>
<td mat-cell *matCellDef="let element">
<div *ngIf="element.ace_type === 'user' else groupId">{{getNameByUuidFromEndpoint(element.user_id)}}</div>
<ng-template #groupId>{{getNameByUuidFromEndpoint(element.group_id)}}</ng-template>
</td>
</ng-container>
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Role</th>
<td mat-cell *matCellDef="let element"> {{getNameByUuidFromEndpoint(element.role_id)}} </td>
</ng-container>
<ng-container matColumnDef="propagate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Propagate</th>
<td mat-cell *matCellDef="let element"> {{element.propagate}} </td>
</ng-container>
<ng-container matColumnDef="allowed">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Allowed</th>
<td mat-cell *matCellDef="let element"> {{element.allowed}} </td>
</ng-container>
<ng-container matColumnDef="created_at">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Created</th>
<td mat-cell *matCellDef="let element"> {{element.created_at}} </td>
</ng-container>
<ng-container matColumnDef="updated_at">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Last update</th>
<td mat-cell *matCellDef="let element"> {{element.updated_at}} </td>
</ng-container>
<ng-container matColumnDef="delete">
<th mat-header-cell *matHeaderCellDef> </th>
<td mat-cell *matCellDef="let element"><button mat-button (click)="onDelete(element)"><mat-icon>delete</mat-icon></button></td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator #acesPaginator="matPaginator"
[pageSizeOptions]="[5, 10, 20]"
showFirstLastButtons
aria-label="Select page">
</mat-paginator>
</div>
</div>
<ng-template #loading>
<div>
<mat-spinner class="loader"></mat-spinner>
</div>
</ng-template>

View File

@ -0,0 +1,26 @@
table {
width: 100%;
}
.full-width {
width: 940px;
margin-left: -470px;
left: 50%;
}
.add-ace-button {
height: 40px;
width: 160px;
margin: 20px;
}
.loader {
position: absolute;
margin: auto;
height: 175px;
bottom: 0;
left: 0;
right: 0;
top: 0;
width: 175px;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AceManagementComponent } from './ace-management.component';
describe('AceManagementComponent', () => {
let component: AceManagementComponent;
let fixture: ComponentFixture<AceManagementComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AceManagementComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(AceManagementComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,176 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2023 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import {Component, OnInit, QueryList, ViewChildren} from '@angular/core';
import {Controller} from "@models/controller";
import {SelectionModel} from "@angular/cdk/collections";
import {Group} from "@models/groups/group";
import {MatTableDataSource} from "@angular/material/table";
import {ACE} from "@models/api/ACE";
import {ActivatedRoute} from "@angular/router";
import {ControllerService} from "@services/controller.service";
import {ToasterService} from "@services/toaster.service";
import {GroupService} from "@services/group.service";
import {MatDialog} from "@angular/material/dialog";
import {AclService} from "@services/acl.service";
import {MatPaginator} from "@angular/material/paginator";
import {MatSort} from "@angular/material/sort";
import {AddUserDialogComponent} from "@components/user-management/add-user-dialog/add-user-dialog.component";
import {AddAceDialogComponent} from "@components/ace-management/add-ace-dialog/add-ace-dialog.component";
import {DeleteUserDialogComponent} from "@components/user-management/delete-user-dialog/delete-user-dialog.component";
import {DeleteAceDialogComponent} from "@components/ace-management/delete-ace-dialog/delete-ace-dialog.component";
import {User} from "@models/users/user";
import {Endpoint} from "@models/api/endpoint";
@Component({
selector: 'app-ace-management',
templateUrl: './ace-management.component.html',
styleUrls: ['./ace-management.component.scss']
})
export class AceManagementComponent implements OnInit {
@ViewChildren('acesPaginator') acesPaginator: QueryList<MatPaginator>;
@ViewChildren('acesSort') acesSort: QueryList<MatSort>;
controller: Controller;
public displayedColumns = ['select', 'path', 'user/group', 'role', 'propagate', 'allowed', 'updated_at', 'delete'];
selection = new SelectionModel<ACE>(true, []);
aces: ACE[];
dataSource = new MatTableDataSource<ACE>();
isReady = false;
searchText = '';
endpoints: Endpoint[];
constructor(private route: ActivatedRoute,
private controllerService: ControllerService,
private toasterService: ToasterService,
public aclService: AclService,
public dialog: MatDialog) { }
ngOnInit(): void {
const controllerId = this.route.parent.snapshot.paramMap.get('controller_id');
this.controllerService.get(+controllerId).then((controller: Controller) => {
this.controller = controller;
this.aclService.getEndpoints(this.controller)
.subscribe((endpoints: Endpoint[]) => {
this.endpoints = endpoints
this.refresh();
})
});
}
ngAfterViewInit() {
this.acesPaginator.changes.subscribe((comps: QueryList <MatPaginator>) =>
{
this.dataSource.paginator = comps.first;
});
this.acesSort.changes.subscribe((comps: QueryList<MatSort>) => {
this.dataSource.sort = comps.first;
})
this.dataSource.sortingDataAccessor = (item, property) => {
switch (property) {
case 'path':
case 'user/group':
case 'role':
return item[property] ? item[property].toLowerCase() : '';
default:
return item[property];
}
};
}
refresh() {
this.aclService.list(this.controller).subscribe((aces: ACE[]) => {
this.isReady = true;
this.aces = aces
this.dataSource.data = aces;
this.selection.clear();
});
}
addACE() {
const dialogRef = this.dialog.open(AddAceDialogComponent, {
width: '1200px',
height: '500px',
autoFocus: false,
disableClose: true,
data: {endpoints: this.endpoints}
});
let instance = dialogRef.componentInstance;
instance.controller = this.controller;
dialogRef.afterClosed().subscribe(() => this.refresh());
}
onDelete(ace: ACE) {
this.dialog
.open(DeleteAceDialogComponent, {width: '500px', data: {aces: [ace]}})
.afterClosed()
.subscribe((isDeletedConfirm) => {
if (isDeletedConfirm) {
this.aclService.delete(this.controller, ace.ace_id)
.subscribe(() => {
this.refresh()
}, (error) => {
this.toasterService.error(`An error occur while trying to delete ace ${ace.ace_id}`);
});
}
});
}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.aces.length;
return numSelected === numRows;
}
masterToggle() {
this.isAllSelected() ?
this.selection.clear() :
this.aces.forEach(row => this.selection.select(row));
}
deleteMultiple() {
this.dialog
.open(DeleteAceDialogComponent, {width: '500px', data: {aces: this.selection.selected}})
.afterClosed()
.subscribe((isDeletedConfirm) => {
if (isDeletedConfirm) {
this.selection.selected.forEach((ace: ACE) => {
this.aclService.delete(this.controller, ace.ace_id)
.subscribe(() => {
this.refresh()
}, (error) => {
this.toasterService.error(`An error occur while trying to delete ace ${ace.ace_id}`);
});
})
this.selection.clear();
}
});
}
getNameByUuidFromEndpoint(uuid: string): string {
if (this.endpoints) {
const elt = this.endpoints.filter((endpoint: Endpoint) => endpoint.endpoint.includes(uuid))
if (elt.length >= 1) {
return elt[0].name
}
}
return ''
}
}

View File

@ -0,0 +1,71 @@
import {EndpointTreeAdapter} from "@components/ace-management/add-ace-dialog/EndpointTreeAdapter";
import {Endpoint, RessourceType} from "@models/api/endpoint";
const endpoint1: Endpoint = {
endpoint: "/",
endpoint_type: RessourceType.image,
name: "Root"
}
const endpoint2: Endpoint = {
endpoint: "/projects",
endpoint_type: RessourceType.project,
name: "All projects"
}
const endpoint3: Endpoint = {
endpoint: "/images",
endpoint_type: RessourceType.image,
name: "All images"
}
const endpoint4: Endpoint = {
endpoint: "/projects/blabla",
endpoint_type: RessourceType.project,
name: "Project blabla"
}
const endpoint5 : Endpoint = {
endpoint: "/projects/blabla/nodes",
endpoint_type: RessourceType.node,
name: "All nodes for project blabla"
}
const endpoint6 : Endpoint = {
endpoint: "/images/blabla",
endpoint_type: RessourceType.image,
name: "Image blabla"
}
let endpoints: Endpoint[] = [endpoint1, endpoint2, endpoint3, endpoint4, endpoint5, endpoint6];
describe('EndpointTreeAdapter', () => {
it('Should build endpointTree', () => {
const adapter = new EndpointTreeAdapter(endpoints);
const tree = adapter.buildTreeFromEndpoints()
expect(tree.length).toEqual(1);
expect(tree[0].children.length).toEqual(2);
const projectEndpoint = tree[0].children[0];
expect(projectEndpoint.children.length).toEqual(1);
expect(projectEndpoint.children[0].children.length).toEqual(1);
const imageEndpoint = tree[0].children[0];
expect(imageEndpoint.children.length).toEqual(1)
expect(imageEndpoint.children[0].children.length).toEqual(0);
});
it('Should build empty tree', () => {
const adapter = new EndpointTreeAdapter([]);
const tree = adapter.buildTreeFromEndpoints()
expect(tree.length).toEqual(0);
})
})

View File

@ -0,0 +1,59 @@
import {Endpoint, RessourceType} from "../../../models/api/endpoint";
export interface EndpointNode {
endpoint: string;
name: string;
endpoint_type: RessourceType;
depth: number;
splitEndp: string[];
parent?: string[];
children?: EndpointNode[];
}
export class EndpointTreeAdapter {
private endpoints: Endpoint[]
constructor(endpoints: Endpoint[]) {
this.endpoints = endpoints
}
buildTreeFromEndpoints(): EndpointNode[] {
const parentNode: EndpointNode[] = []
let nodes = []
this.endpoints.forEach((endp: Endpoint) => {
const node = this.extractParent(endp)
nodes.push(node)
})
nodes.forEach((node: EndpointNode) => {
if(node.depth > 0) {
const parent = nodes.filter((n: EndpointNode) => n.splitEndp.join('/') == node.splitEndp.slice(0, node.depth-1).join('/'))[0]
parent.children.push(node)
}
})
parentNode.push(nodes.find((node: EndpointNode) => node.depth === 0))
return parentNode
}
private extractParent(endp: Endpoint): EndpointNode {
let splitEndp = endp.endpoint.split('/');
splitEndp = splitEndp.filter((value: string) => value !== '' && value !== 'access')
let parent = [];
if (splitEndp.length > 0) {
parent = splitEndp.slice(0,splitEndp.length - 1)
}
const node: EndpointNode = {
children: [],
depth: splitEndp.length,
splitEndp: splitEndp,
endpoint: endp.endpoint,
endpoint_type: endp.endpoint_type,
name: endp.name,
parent: parent
}
return node
}
}

View File

@ -0,0 +1,108 @@
<h1 mat-dialog-title>Create new ACE</h1>
<form [formGroup]="addAceForm" class="input-field d-flex" style="height: 380px">
<div style="width: 75%; float: left; overflow-y: auto" class="d-inline-block">
<cdk-tree [dataSource]="treeDataSource" [treeControl]="treeControl">
<!-- This is the tree node template for leaf nodes -->
<cdk-nested-tree-node *cdkTreeNodeDef="let node" class="example-tree-node">
<!-- use a disabled button to provide padding for tree leaf -->
<div [class.selected]="selectedEndpoint && node.endpoint === selectedEndpoint.endpoint">
<button mat-icon-button disabled></button>
{{node.name}}
<button mat-icon-button (click)="endpointSelection(node)">
<div *ngIf="!selectedEndpoint || node.endpoint !== selectedEndpoint.endpoint">
<mat-icon class="align-middle" style="color: #696969">panorama_fish_eye</mat-icon>
</div>
<div *ngIf="selectedEndpoint && node.endpoint === selectedEndpoint.endpoint" >
<mat-icon class="align-middle">check_circle</mat-icon>
</div>
</button>
</div>
</cdk-nested-tree-node>
<!-- This is the tree node template for expandable nodes -->
<cdk-nested-tree-node *cdkTreeNodeDef="let node; when: hasChild" class="example-tree-node" >
<div [class.selected]="selectedEndpoint && node.endpoint === selectedEndpoint.endpoint">
<button mat-icon-button [attr.aria-label]="'Toggle ' + node.name" cdkTreeNodeToggle>
<mat-icon class="mat-icon-rtl-mirror">
{{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
</mat-icon>
</button>
{{node.name}}
<button mat-icon-button (click)="endpointSelection(node)">
<div *ngIf="!selectedEndpoint || node.endpoint !== selectedEndpoint.endpoint">
<mat-icon class="align-middle" style="color: #696969">panorama_fish_eye</mat-icon>
</div>
<div *ngIf="selectedEndpoint && node.endpoint === selectedEndpoint.endpoint">
<mat-icon class="align-middle">check_circle</mat-icon>
</div>
</button>
</div>
<div [class.example-tree-invisible]="!treeControl.isExpanded(node)">
<ng-container cdkTreeNodeOutlet></ng-container>
</div>
</cdk-nested-tree-node>
</cdk-tree>
</div>
<mat-divider [vertical]="true" style="height: auto;"></mat-divider>
<div class="form-div d-inline-block h-100">
<div>
<div class="typeSelect">
<mat-select placeholder="User/Group" formControlName="type">
<mat-option *ngFor="let t of types" [value]="t">{{t.charAt(0).toUpperCase() + t.slice(1)}}</mat-option>
</mat-select>
</div>
<app-autocomplete *ngIf="form.type.value === 'user'"
[data]="users"
[eltType]="'Users'"
[displayFn]="displayFnUser"
[filterFn]="_filterUser"
(onSelection)="userSelection($event)">
</app-autocomplete>
<app-autocomplete *ngIf="form.type.value === 'group'"
[data]="groups"
[eltType]="'Groups'"
[displayFn]="displayFn"
[filterFn]="_filter"
(onSelection)="groupSelection($event)">
</app-autocomplete>
<app-autocomplete
[data]="roles"
[eltType]="'Roles'"
[displayFn]="displayFn"
[filterFn]="_filter"
(onSelection)="roleSelection($event)">
</app-autocomplete>
<div class="d-flex justify-content-between">
<div class="d-inline-block" style="float:left;">
<mat-checkbox formControlName="propagate" >Propagate</mat-checkbox>
</div>
<mat-divider [vertical]="true" style="height: auto;"></mat-divider>
<div class="d-inline-block" style="float: right">
<button *ngIf="allowed" class="allow "
mat-button
(click)="changeAllowed()">ALLOWED
</button>
<button *ngIf="!allowed" class="deny"
mat-button
(click)="changeAllowed()">DENY
</button>
</div>
</div>
</div>
<div mat-dialog-actions class="button-div">
<button mat-button (click)="onCancelClick()" color="accent">Cancel</button>
<button mat-button (click)="onAddClick()" tabindex="2" mat-raised-button color="primary"
[disabled]="!addAceForm.valid">
Add ACE
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,71 @@
.input-field {
width: 100%;
}
.height-100 {
height: 100%;
}
.button-div {
float: right;
position: absolute;
right: 0;
bottom: 0;
}
.allow {
background-color: green;
}
.deny {
background-color: darkred;
}
.typeSelect {
height: 25px;
margin-left: 5px;
margin-right: 5px;
}
.groupList {
display: flex;
margin: 10px;
justify-content: space-between;
flex: 1 1 auto
}
.groups {
display: flex;
height: 180px;
overflow: auto;
flex-direction: column;
}
.example-tree-invisible {
display: none;
}
.example-tree ul,
.example-tree li {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
}
.example-tree-node {
display: block;
}
.example-tree-node .example-tree-node {
padding-left: 40px;
}
.form-div {
position: relative;
width: 25%;
float:right;
margin-left: 20px;
}
.selected {
color: green
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AddAceDialogComponent } from './add-ace-dialog.component';
describe('AddAceDialogComponent', () => {
let component: AddAceDialogComponent;
let fixture: ComponentFixture<AddAceDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AddAceDialogComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(AddAceDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,191 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2023 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {UserService} from "@services/user.service";
import {ToasterService} from "@services/toaster.service";
import {AclService} from "@services/acl.service";
import {Controller} from "@models/controller";
import {Endpoint, RessourceType} from "@models/api/endpoint";
import {UntypedFormControl, UntypedFormGroup} from "@angular/forms";
import {ACE, AceType} from "@models/api/ACE";
import {Group} from "@models/groups/group";
import {GroupService} from "@services/group.service";
import {User} from "@models/users/user";
import {Role} from "@models/api/role";
import {RoleService} from "@services/role.service";
import {NestedTreeControl} from "@angular/cdk/tree";
import {ArrayDataSource} from "@angular/cdk/collections";
import {EndpointNode, EndpointTreeAdapter} from "@components/ace-management/add-ace-dialog/EndpointTreeAdapter";
@Component({
selector: 'app-add-ace-dialog',
templateUrl: './add-ace-dialog.component.html',
styleUrls: ['./add-ace-dialog.component.scss']
})
export class AddAceDialogComponent implements OnInit {
controller: Controller
addAceForm: UntypedFormGroup
allowed: boolean = true
types = Object.values(AceType);
endpoints: Endpoint[];
selectedEndpoint: Endpoint
filteredEndpoint: Endpoint[]
endpointTypes: string[]
groups: Group[];
selectedGroup: Group;
users: User[];
selectedUser: User;
roles: Role[];
selectedRole: Role;
TREE_DATA: EndpointNode[] = [];
treeControl = new NestedTreeControl<EndpointNode>(node => node.children);
treeDataSource: ArrayDataSource<EndpointNode> ;
constructor(public dialogRef: MatDialogRef<AddAceDialogComponent>,
public aclService: AclService,
public userService: UserService,
private groupService: GroupService,
private roleService: RoleService,
private toasterService: ToasterService,
@Inject(MAT_DIALOG_DATA) public data: { endpoints: Endpoint[] }) {
this.endpoints = data.endpoints
const treeAdapter = new EndpointTreeAdapter(this.endpoints)
const data_tree = treeAdapter.buildTreeFromEndpoints()
this.treeDataSource = new ArrayDataSource(data_tree);
console.log(data_tree)
}
ngOnInit(): void {
this.addAceForm = new UntypedFormGroup({
type: new UntypedFormControl(AceType.user),
role_id: new UntypedFormControl(),
propagate: new UntypedFormControl(true)
});
this.groupService.getGroups(this.controller)
.subscribe((groups: Group[]) => {
this.groups = groups;
})
this.userService.list(this.controller)
.subscribe((users: User[]) => {
this.users = users;
})
this.roleService.get(this.controller)
.subscribe((roles: Role[]) => {
this.roles = roles;
})
}
get form() {
return this.addAceForm.controls;
}
onCancelClick() {
this.dialogRef.close();
}
onAddClick() {
const ACE = {
ace_type: this.form.type.value,
allowed: this.allowed,
group_id: this.form.type.value === AceType.group ? this.selectedGroup.user_group_id : null,
path: this.selectedEndpoint.endpoint,
propagate: this.form.propagate.value,
role_id: this.selectedRole.role_id,
user_id: this.form.type.value === AceType.user ? this.selectedUser.user_id : null,
}
if (ACE.path && ACE.role_id && (ACE.user_id || ACE.group_id)) {
this.aclService.add(this.controller, ACE)
.subscribe((ace: ACE) => {
this.toasterService.success(`ACE was added for path ${ACE.path}`);
},
(error) => {
this.toasterService.error(`Cannot create ACE : ${error.error.message}`)
})
this.dialogRef.close();
}
}
changeAllowed() {
this.allowed = !this.allowed
}
displayFn(value): string {
return value && value.name ? value.name : '';
}
displayFnUser(value): string {
return value && value.full_name && value.username ? value.username.concat(" - ", value.full_name) : '';
}
_filter(value: string, data: any): any {
if (typeof value === 'string' && data) {
const filterValue = value.toLowerCase();
return data.filter(option => option.name.toLowerCase().includes(filterValue));
}
}
_filterUser(value: string, users: User[]): User[] {
if (typeof value === 'string' && users) {
const filterValue = value.toLowerCase();
return users.filter(option => option.full_name.toLowerCase().includes(filterValue) || option.username.toLowerCase().includes(filterValue));
}
}
_filterRole(value: string, roles: Role[]) {
if (typeof value === 'string' && roles) {
const filterValue = value.toLowerCase();
return roles.filter(option => option.name.toLowerCase().includes(filterValue));
}
}
userSelection(value: any) {
this.selectedUser = value
}
groupSelection(value: any) {
this.selectedGroup = value
}
roleSelection(value: any) {
this.selectedRole = value;
}
endpointSelection(value: EndpointNode) {
const endp: Endpoint = {
endpoint: value.endpoint,
endpoint_type: value.endpoint_type,
name: value.name
}
this.selectedEndpoint = endp
}
hasChild = (_: number, node: EndpointNode) => !!node.children && node.children.length > 0;
}

View File

@ -0,0 +1,12 @@
<mat-form-field class="input-field">
<mat-label>{{eltType}}</mat-label>
<input type="text"
matInput
[matAutocomplete]="auto"
[formControl]="autocompleteControl">
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn" (optionSelected)='onSelection.emit($event.option.value)'>
<mat-option *ngFor="let elt of filteredData | async" [value]="elt" >
{{displayFn(elt)}}
</mat-option>
</mat-autocomplete>
</mat-form-field>

View File

@ -0,0 +1,3 @@
.input-field {
width: 100%;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AutocompleteComponent } from './autocomplete.component';
describe('AutocompleteComponent', () => {
let component: AutocompleteComponent<any>;
let fixture: ComponentFixture<AutocompleteComponent<any>>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AutocompleteComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(AutocompleteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
import {Group} from "@models/groups/group";
import {Observable} from "rxjs";
import {UntypedFormControl} from "@angular/forms";
import {map, startWith} from "rxjs/operators";
import {data} from "autoprefixer";
@Component({
selector: 'app-autocomplete',
templateUrl: './autocomplete.component.html',
styleUrls: ['./autocomplete.component.scss']
})
export class AutocompleteComponent<T> implements OnChanges {
@Input() data: T[];
filteredData: Observable<T[]>;
typeName: string
autocompleteControl = new UntypedFormControl();
@Input() eltType: string
@Input() displayFn: (value: T) => string
@Input() filterFn: (value: string, data: T[]) => T[]
@Output() onSelection: EventEmitter<T> = new EventEmitter<T>();
constructor() { }
ngOnChanges(): void {
this.filteredData = this.autocompleteControl.valueChanges.pipe(
startWith(''),
map(value => this.filterFn(value, this.data))
)
}
}

View File

@ -0,0 +1,10 @@
<h1 mat-dialog-title>Are you sure you want to delete the following ACEs ?</h1>
<ul>
<li *ngFor="let ace of data.aces">{{ ace.path }} </li>
</ul>
<div mat-dialog-actions class="button-div">
<button mat-button (click)="onCancel()" color="accent">No, cancel</button>
<button mat-button (click)="onDelete()" tabindex="2" class="add-project-button" mat-raised-button color="primary">
Yes, delete!
</button>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DeleteAceDialogComponent } from './delete-ace-dialog.component';
describe('DeleteAceDialogComponent', () => {
let component: DeleteAceDialogComponent;
let fixture: ComponentFixture<DeleteAceDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DeleteAceDialogComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(DeleteAceDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,38 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2023 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ACE} from "@models/api/ACE";
@Component({
selector: 'app-delete-ace-dialog',
templateUrl: './delete-ace-dialog.component.html',
styleUrls: ['./delete-ace-dialog.component.scss']
})
export class DeleteAceDialogComponent implements OnInit {
constructor(private dialogRef: MatDialogRef<DeleteAceDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: { aces: ACE[] }) { }
ngOnInit(): void {
}
onCancel() {
this.dialogRef.close();
}
onDelete() {
this.dialogRef.close(true);
}
}

View File

@ -23,7 +23,7 @@ import {ControllerService} from "@services/controller.service";
export class ManagementComponent implements OnInit {
controller: Controller;
links = ['users', 'groups', 'roles'];
links = ['users', 'groups', 'roles', 'resourcePools', 'aces'];
activeLink: string = this.links[0];
constructor(

View File

@ -0,0 +1,5 @@
<div class="title">delete resource {{data.resource_type}}/{{data.name}} ?</div>
<div class="button">
<button mat-raised-button color="warn" (click)="dialogRef.close()">Cancel</button>
<button mat-raised-button color="primary" (click)="dialogRef.close(data)">Update</button>
</div>

View File

@ -0,0 +1,15 @@
:host {
display: flex;
margin: 30px;
flex-direction: column;
}
.title {
margin-bottom: 50px;
}
.button {
display: flex;
flex-direction: row;
justify-content: space-between;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DeleteResourceConfirmationDialogComponent } from './delete-resource-confirmation-dialog.component';
describe('DeleteResourceConfirmationDialogComponent', () => {
let component: DeleteResourceConfirmationDialogComponent;
let fixture: ComponentFixture<DeleteResourceConfirmationDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DeleteResourceConfirmationDialogComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(DeleteResourceConfirmationDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,19 @@
import {Component, Inject, OnInit} from '@angular/core';
import {DIALOG_DATA} from "@angular/cdk/dialog";
import {Resource} from "@models/resourcePools/Resource";
import {MatDialogRef} from "@angular/material/dialog";
@Component({
selector: 'app-delete-resource-confirmation-dialog',
templateUrl: './delete-resource-confirmation-dialog.component.html',
styleUrls: ['./delete-resource-confirmation-dialog.component.scss']
})
export class DeleteResourceConfirmationDialogComponent implements OnInit {
constructor(@Inject(DIALOG_DATA) public data: Resource,
public dialogRef: MatDialogRef<DeleteResourceConfirmationDialogComponent>,) { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,62 @@
<div class="content">
<div class="default-header">
<div class="row align-items-center">
<a
mat-icon-button
matTooltip="back to resource pools management"
mattooltipclass="custom-tooltip"
[routerLink]="['/controller', controller.id, 'management', 'resourcePools']">
<mat-icon aria-label="back to resource pools management">keyboard_arrow_left</mat-icon>
</a>
<h1 class="col">role {{pool.name}} details</h1>
</div>
<div class="main">
<div class="details">
<div>
<mat-form-field>
<mat-label>pool name:</mat-label>
<input matInput type="text" [(ngModel)]="pool.name">
</mat-form-field>
</div>
<div>creation date: {{pool.created_at}}</div>
<div>last update date: {{pool.updated_at}}</div>
<div>uuid: {{pool.resource_pool_id}}</div>
<div mat-dialog-actions class="button-div">
<button mat-button (click)="onUpdate()" tabindex="2" mat-raised-button color="primary">
update role
</button>
</div>
</div>
<mat-divider [vertical]="true"></mat-divider>
<div class="resources">
<div class="addResource">
<div >
<input type="text"
placeholder="add project to resource pool"
matInput
[formControl]="addResourceFormControl"
[matAutocomplete]="auto">
<mat-autocomplete autoActiveFirstOption #auto="matAutocomplete">
<mat-option *ngFor="let option of addResourceFilteredOptions | async" [value]="option">
{{option}}
</mat-option>
</mat-autocomplete>
</div>
<div>
<button mat-button>
<mat-icon (click)="addResource()">add</mat-icon>
</button>
</div>
</div>
<div class="ownedResources" *ngFor="let resource of pool.resources">
<div>{{resource.name}}</div>
<div>
<button mat-button>
<mat-icon (click)="deleteResource(resource)">delete</mat-icon>
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,67 @@
.main {
display: flex;
justify-content: space-around;
}
.details {
width: 30vw;
display: flex;
flex-direction: column;
justify-content: center;
}
.clickable {
cursor: pointer;
}
.privilege {
display: flex;
flex-direction: row;
padding-left: 10px;
justify-content: space-between;
}
.permission {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 10px;
border: 1px solid;
padding: 5px;
border-radius: 5px;
font-family: monospace;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
padding-bottom: 20px;
padding-left: 10px;
}
.header > div {
font-size: 2em;
}
.resources {
display: flex;
flex-direction: column;
}
.ownedResources {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.addResource {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResourcePoolDetailsComponent } from './resource-pool-details.component';
describe('ResourcePoolDetailsComponent', () => {
let component: ResourcePoolDetailsComponent;
let fixture: ComponentFixture<ResourcePoolDetailsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ResourcePoolDetailsComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ResourcePoolDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,122 @@
import {Component, OnInit} from '@angular/core';
import {Controller} from "@models/controller";
import {FormControl, UntypedFormControl, UntypedFormGroup} from "@angular/forms";
import {ToasterService} from "@services/toaster.service";
import {ActivatedRoute} from "@angular/router";
import {ResourcePool} from "@models/resourcePools/ResourcePool";
import {ResourcePoolsService} from "@services/resource-pools.service";
import {ProjectService} from "@services/project.service";
import {filter, map, startWith, switchMap} from "rxjs/operators";
import {Project} from "@models/project";
import {BehaviorSubject, Observable, of} from "rxjs";
import {Resource} from "@models/resourcePools/Resource";
import {MatDialog} from "@angular/material/dialog";
import {
DeleteResourceConfirmationDialogComponent
} from "@components/resource-pool-details/delete-resource-confirmation-dialog/delete-resource-confirmation-dialog.component";
@Component({
selector: 'app-resource-pool-details',
templateUrl: './resource-pool-details.component.html',
styleUrls: ['./resource-pool-details.component.scss']
})
export class ResourcePoolDetailsComponent implements OnInit {
controller: Controller;
editPoolForm: UntypedFormGroup;
pool: ResourcePool;
addResourceFormControl = new FormControl('');
addResourceFilteredOptions: Observable<string[]>;
projects: Project[] = [];
constructor(private toastService: ToasterService,
private route: ActivatedRoute,
private resourcePoolsService: ResourcePoolsService,
private dialog: MatDialog,
) {
this.editPoolForm = new UntypedFormGroup({
poolname: new UntypedFormControl(),
});
}
ngOnInit(): void {
this.route.data.subscribe((d: { controller: Controller; pool: ResourcePool }) => {
this.controller = d.controller;
this.pool = d.pool;
this.refresh();
});
}
onUpdate() {
this.resourcePoolsService.update(this.controller, this.pool)
.subscribe((pool: ResourcePool) => {
this.toastService.success(`pool ${pool.name}, updated`);
});
}
addResource() {
const selected = this.addResourceFormControl.value;
const project = this.projects.filter( p => p.name.includes(selected));
if(project.length === 1) {
this.resourcePoolsService.addResource(this.controller,this.pool, project[0])
.subscribe(() => {
this.toastService.success(`project : ${project[0].name}, added to pool: ${this.pool.name}`);
this.refresh();
this.addResourceFormControl.setValue('');
return;
});
return;
}
if(project.length === 0) {
this.toastService.error(`cannot found related project with string: ${selected}`);
return;
}
if(project.length > 1) {
this.toastService.error(`${project.length} match ${selected}, please be more accurate`);
return;
}
}
deleteResource(resource: Resource) {
this.dialog.open(DeleteResourceConfirmationDialogComponent, {data: resource})
.afterClosed()
.subscribe((resource: Resource) => {
if(resource) {
this.resourcePoolsService
.deleteResource(this.controller, resource, this.pool)
.subscribe(() => {
this.refresh()
this.toastService.success(`resource ${resource.name} delete from pool ${this.pool.name}`)
});
}
})
}
private refresh() {
this.resourcePoolsService
.get(this.controller, this.pool.resource_pool_id)
.subscribe((pool) => {
this.pool = pool;
});
this.resourcePoolsService
.getFreeResources(this.controller)
.subscribe((projects: Project[]) => {
this.projects = projects;
this.addResourceFilteredOptions = this.addResourceFormControl.valueChanges.pipe(
startWith(''),
map((value: string) => {
return this.projects
.filter((project: Project) => project.name.toLowerCase().includes(value.toLowerCase() || ''))
.map((project: Project) => project.name);
})
);
});
}
}

View File

@ -0,0 +1,29 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2022 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import { UntypedFormControl } from '@angular/forms';
import { timer } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { Controller } from "@models/controller";
import {ResourcePoolsService} from "@services/resource-pools.service";
import {ResourcePool} from "@models/resourcePools/ResourcePool";
export const poolNameAsyncValidator = (controller: Controller, resourcePoolsService: ResourcePoolsService) => {
return (control: UntypedFormControl) => {
return timer(500).pipe(
switchMap(() => resourcePoolsService.getAll(controller)),
map((response: ResourcePool[]) => {
return (response.find((n) => n.name === control.value) ? { projectExist: true } : null);
})
);
};
};

View File

@ -0,0 +1,26 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2022 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import { Injectable } from '@angular/core';
@Injectable()
export class PoolNameValidator {
get(poolName) {
const pattern = new RegExp(/[~`!#$%\^&*+=\[\]\\';,/{}|\\":<>\?]/);
if (!pattern.test(poolName.value)) {
return null;
}
return { invalidName: true };
}
}

View File

@ -0,0 +1,30 @@
<h1 mat-dialog-title>Create new pool</h1>
<form [formGroup]="poolNameForm" class="file-name-form">
<mat-form-field class="file-name-form-field">
<input
matInput
(keydown)="onKeyDown($event)"
type="text"
formControlName="poolName"
[ngClass]="{ 'is-invalid': form.poolName?.errors }"
placeholder="Please enter a pool name"
/>
<mat-error *ngIf="form.poolName?.touched && form.poolName?.errors && form.poolName?.errors.required"
>Pool name is required</mat-error
>
<mat-error *ngIf="form.poolName?.errors && form.poolName?.errors.invalidName"
>Pool name is incorrect</mat-error
>
<mat-error *ngIf="form.poolName?.errors && form.poolName?.errors.projectExist"
>Pool with this name exists</mat-error
>
</mat-form-field>
</form>
<div mat-dialog-actions class="button-div">
<button mat-button (click)="onNoClick()" color="accent">Cancel</button>
<button mat-button (click)="onAddClick()" tabindex="2" class="add-project-button" mat-raised-button color="primary">
Add Pool
</button>
</div>

View File

@ -0,0 +1,25 @@
.file-name-form-field {
width: 100%;
}
.project-snackbar {
background: #2196f3;
}
.userList {
display: flex;
margin: 10px;
justify-content: space-between;
flex: 1 1 auto;
}
.users {
display: flex;
height: 180px;
overflow: auto;
flex-direction: column;
}
.button-div {
float: right;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AddResourcePoolDialogComponent } from './add-resource-pool-dialog.component';
describe('AddResourcePoolDialogComponent', () => {
let component: AddResourcePoolDialogComponent;
let fixture: ComponentFixture<AddResourcePoolDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AddResourcePoolDialogComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(AddResourcePoolDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,67 @@
import {Component, Inject, OnInit} from '@angular/core';
import {UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators} from "@angular/forms";
import {Controller} from "@models/controller";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {PoolNameValidator} from "@components/resource-pools-management/add-resource-pool-dialog/PoolNameValidator";
import {ResourcePoolsService} from "@services/resource-pools.service";
import {ToasterService} from "@services/toaster.service";
import {poolNameAsyncValidator} from "@components/resource-pools-management/add-resource-pool-dialog/PoolNameAsyncValidator";
@Component({
selector: 'app-add-resource-pool-dialog',
templateUrl: './add-resource-pool-dialog.component.html',
styleUrls: ['./add-resource-pool-dialog.component.scss'],
providers: [PoolNameValidator]
})
export class AddResourcePoolDialogComponent implements OnInit {
poolNameForm: UntypedFormGroup;
controller: Controller;
constructor(private dialogRef: MatDialogRef<AddResourcePoolDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: { controller: Controller },
private formBuilder: UntypedFormBuilder,
private poolNameValidator: PoolNameValidator,
private resourcePoolsService: ResourcePoolsService,
private toasterService: ToasterService) {
}
ngOnInit(): void {
this.controller = this.data.controller;
this.poolNameForm = this.formBuilder.group({
poolName: new UntypedFormControl(
null,
[Validators.required, this.poolNameValidator.get],
[poolNameAsyncValidator(this.data.controller, this.resourcePoolsService)]
),
});
}
onKeyDown(event) {
if (event.key === 'Enter') {
this.onAddClick();
}
}
get form() {
return this.poolNameForm.controls;
}
onAddClick() {
if (this.poolNameForm.invalid) {
return;
}
const poolName = this.poolNameForm.controls['poolName'].value;
this.resourcePoolsService.add(this.controller, poolName)
.subscribe((pool) => {
this.dialogRef.close(true);
}, (error) => {
this.toasterService.error(`An error occur while trying to create new pool ${poolName}`);
this.dialogRef.close(false);
});
}
onNoClick() {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,8 @@
<h1 mat-dialog-title>Are you sure to delete pools named: </h1>
<p *ngFor="let pool of data.pools">{{pool.name}}</p>
<div mat-dialog-actions>
<button mat-button (click)="onCancel()" color="accent">No, cancel</button>
<button mat-button (click)="onDelete()" tabindex="2" class="add-project-button" mat-raised-button color="primary">
Yes, delete!
</button>
</div>

View File

@ -0,0 +1,6 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DeleteResourcePoolComponent } from './delete-resource-pool.component';
describe('DeleteResourcePoolComponent', () => {
let component: DeleteResourcePoolComponent;
let fixture: ComponentFixture<DeleteResourcePoolComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DeleteResourcePoolComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(DeleteResourcePoolComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2022 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ResourcePool} from "@models/resourcePools/ResourcePool";
@Component({
selector: 'app-delete-resource-pool',
templateUrl: './delete-resource-pool.component.html',
styleUrls: ['./delete-resource-pool.component.scss']
})
export class DeleteResourcePoolComponent implements OnInit {
constructor(private dialogRef: MatDialogRef<DeleteResourcePoolComponent>,
@Inject(MAT_DIALOG_DATA) public data: { pools: ResourcePool[] }) {}
ngOnInit(): void {
}
onCancel() {
this.dialogRef.close();
}
onDelete() {
this.dialogRef.close(true);
}
}

View File

@ -0,0 +1,8 @@
import { ResourcePoolsFilterPipe } from './resource-pools-filter.pipe';
describe('ResourcePoolsFilterPipe', () => {
it('create an instance', () => {
const pipe = new ResourcePoolsFilterPipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import {Pipe, PipeTransform} from '@angular/core';
import {ResourcePool} from "@models/resourcePools/ResourcePool";
import {MatTableDataSource} from "@angular/material/table";
@Pipe({
name: 'resourcePoolsFilter'
})
export class ResourcePoolsFilterPipe implements PipeTransform {
transform(resourcePool: MatTableDataSource<ResourcePool>, searchText: string): MatTableDataSource<ResourcePool> {
if (!searchText) {
return resourcePool;
}
searchText = searchText.trim().toLowerCase();
resourcePool.filter = searchText;
return resourcePool;
}
}

View File

@ -0,0 +1,82 @@
<div class="content" *ngIf="isReady; else loading">
<div class="default-header">
<div class="row">
<h1 class="col">Resource Pools management</h1>
<button class="col" mat-raised-button color="primary" (click)="onDelete(selection.selected)"
class="add-ressourcePool-button" [disabled]="selection.selected.length == 0">
Delete selected pools
</button>
<button class="col" mat-raised-button color="primary" (click)="addResourcePool()" class="add-ressourcePool-button">
Add resource pool
</button>
</div>
</div>
<form>
<mat-form-field class="full-width">
<input matInput placeholder="Search by name" [(ngModel)]="searchText"
[ngModelOptions]="{ standalone: true }"/>
</mat-form-field>
</form>
<div class="default-content">
<table mat-table [dataSource]="dataSource | resourcePoolsFilter: searchText" class="mat-elevation-z8" matSort
#resourcePoolsSort="matSort">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef class="small-col">
<mat-checkbox (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row" class="small-col">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Name</th>
<td mat-cell *matCellDef="let element"><a class="table-link"
routerLink="/controller/{{controller.id}}/management/resourcePools/{{element.resource_pool_id}}">{{element.name}}</a>
</td>
</ng-container>
<ng-container matColumnDef="created_at">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Creation date</th>
<td mat-cell *matCellDef="let element"> {{element.created_at}} </td>
</ng-container>
<ng-container matColumnDef="updated_at">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Last update</th>
<td mat-cell *matCellDef="let element"> {{element.updated_at}} </td>
</ng-container>
<ng-container matColumnDef="delete">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let element">
<button mat-button (click)="onDelete([element])">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator #resourcePoolsPaginator="matPaginator"
[pageSizeOptions]="[5, 10, 20]"
showFirstLastButtons
aria-label="Select page">
</mat-paginator>
</div>
</div>
<ng-template #loading>
<div>
<mat-spinner class="loader"></mat-spinner>
</div>
</ng-template>

View File

@ -0,0 +1,26 @@
table {
width: 100%;
}
.full-width {
width: 940px;
margin-left: -470px;
left: 50%;
}
.add-ressourcePool-button {
height: 40px;
width: 160px;
margin: 20px;
}
.loader {
position: absolute;
margin: auto;
height: 175px;
bottom: 0;
left: 0;
right: 0;
top: 0;
width: 175px;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResourcePoolsManagementComponent } from './resource-pools-management.component';
describe('ResourcePoolsManagementComponent', () => {
let component: ResourcePoolsManagementComponent;
let fixture: ComponentFixture<ResourcePoolsManagementComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ResourcePoolsManagementComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ResourcePoolsManagementComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,123 @@
import {Component, OnInit, QueryList, ViewChildren} from '@angular/core';
import {Controller} from "@models/controller";
import {MatPaginator} from "@angular/material/paginator";
import {MatSort} from "@angular/material/sort";
import {SelectionModel} from "@angular/cdk/collections";
import {MatTableDataSource} from "@angular/material/table";
import {ActivatedRoute} from "@angular/router";
import {ControllerService} from "@services/controller.service";
import {ToasterService} from "@services/toaster.service";
import {MatDialog} from "@angular/material/dialog";
import {forkJoin} from "rxjs";
import {ResourcePool} from "@models/resourcePools/ResourcePool";
import {
AddResourcePoolDialogComponent
} from "@components/resource-pools-management/add-resource-pool-dialog/add-resource-pool-dialog.component";
import {DeleteResourcePoolComponent} from "@components/resource-pools-management/delete-resource-pool/delete-resource-pool.component";
import {ResourcePoolsService} from "@services/resource-pools.service";
@Component({
selector: 'app-resource-pools-management',
templateUrl: './resource-pools-management.component.html',
styleUrls: ['./resource-pools-management.component.scss']
})
export class ResourcePoolsManagementComponent implements OnInit {
controller: Controller;
@ViewChildren('resourcePoolsPaginator') resourcePoolsPaginator: QueryList<MatPaginator>;
@ViewChildren('resourcePoolsSort') resourcePoolsSort: QueryList<MatSort>;
public displayedColumns = ['select', 'name', 'created_at', 'updated_at', 'delete'];
selection = new SelectionModel<ResourcePool>(true, []);
resourcePools: ResourcePool[];
dataSource = new MatTableDataSource<ResourcePool>();
searchText: string;
isReady = false;
constructor(
private route: ActivatedRoute,
private controllerService: ControllerService,
private toasterService: ToasterService,
public resourcePoolsService: ResourcePoolsService,
public dialog: MatDialog
) {
}
ngOnInit(): void {
const controllerId = this.route.parent.snapshot.paramMap.get('controller_id');
this.controllerService.get(+controllerId).then((controller: Controller) => {
this.controller = controller;
this.refresh();
});
}
ngAfterViewInit() {
this.resourcePoolsPaginator.changes.subscribe((comps: QueryList <MatPaginator>) =>
{
this.dataSource.paginator = comps.first;
});
this.resourcePoolsSort.changes.subscribe((comps: QueryList<MatSort>) => {
this.dataSource.sort = comps.first;
});
this.dataSource.sortingDataAccessor = (item, property) => {
switch (property) {
case 'name':
return item[property] ? item[property].toLowerCase() : '';
default:
return item[property];
}
};
}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.resourcePools.length;
return numSelected === numRows;
}
masterToggle() {
this.isAllSelected() ?
this.selection.clear() :
this.resourcePools.forEach(row => this.selection.select(row));
}
addResourcePool() {
this.dialog
.open(AddResourcePoolDialogComponent, {width: '600px', height: '500px', data: {controller: this.controller}})
.afterClosed()
.subscribe((added: boolean) => {
if (added) {
this.refresh();
}
});
}
refresh() {
this.resourcePoolsService.getAll(this.controller).subscribe((resourcePools: ResourcePool[]) => {
this.isReady = true;
this.resourcePools = resourcePools;
this.dataSource.data = resourcePools;
this.selection.clear();
});
}
onDelete(resourcePoolToDelete: ResourcePool[]) {
this.dialog
.open(DeleteResourcePoolComponent, {width: '500px', height: '250px', data: {pools: resourcePoolToDelete}})
.afterClosed()
.subscribe((isDeletedConfirm) => {
if (isDeletedConfirm) {
const observables = resourcePoolToDelete.map((resourcePool: ResourcePool) => this.resourcePoolsService.delete(this.controller, resourcePool.resource_pool_id));
forkJoin(observables)
.subscribe(() => {
this.refresh();
},
(error) => {
this.toasterService.error(`An error occur while trying to delete resource pool`);
});
}
});
}
}

View File

@ -0,0 +1,34 @@
import { Pipe, PipeTransform } from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {User} from "@models/users/user";
import {ACE} from "@models/api/ACE";
import {Endpoint} from "@models/api/endpoint";
@Pipe({
name: 'aceFilter'
})
export class AceFilterPipe implements PipeTransform {
transform(items: MatTableDataSource<ACE>, searchText: string, endpoints: Endpoint[]){
if (!items) return [];
if (!searchText) return items;
searchText = searchText.toLowerCase()
const filteredEndpoints = endpoints.filter((endp: Endpoint) => endp.name.toLowerCase().includes(searchText))
return items.data.filter((item: ACE) => {
const user = this.getEndpoint(item.user_id, endpoints)
const group = this.getEndpoint(item.group_id, endpoints)
const path = this.getEndpoint(item.path, endpoints)
const role = this.getEndpoint(item.role_id, endpoints)
return filteredEndpoints.some((endp: Endpoint) => [user, group, path, role].includes(endp.endpoint))
})
}
private getEndpoint(id: string, endpoints: Endpoint[]): string {
const filter = endpoints.filter((endpoint: Endpoint) => endpoint.endpoint.includes(id))
if(filter.length > 0) {
return filter[0].endpoint.toLowerCase()
}
return ''
}
}

18
src/app/models/api/ACE.ts Normal file
View File

@ -0,0 +1,18 @@
export enum AceType {
group= "group",
user = "user"
}
export interface ACE {
ace_id: string;
ace_type: AceType;
path: string;
propagate: boolean;
allowed: boolean;
user_id?: string;
group_id?: string;
role_id: string;
created_at: string;
updated_at: string;
}

View File

@ -0,0 +1,17 @@
export enum RessourceType {
project = "project",
node = "node",
link = "link",
user = "user",
group = "group",
pool = "pool",
image = "image",
template = "template",
root = "root"
}
export interface Endpoint {
endpoint: string,
name: string,
endpoint_type: RessourceType
}

View File

@ -0,0 +1,7 @@
export class Resource {
resource_id: string;
resource_type: string;
name: string;
created_at: string;
updated_at: string;
}

View File

@ -0,0 +1,9 @@
import {Resource} from "@models/resourcePools/Resource";
export class ResourcePool {
name: string;
created_at: string;
updated_at: string;
resource_pool_id: string;
resources?: Resource[];
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ResourcePoolsResolver } from './resource-pools.resolver';
describe('ResourcePoolsResolver', () => {
let resolver: ResourcePoolsResolver;
beforeEach(() => {
TestBed.configureTestingModule({});
resolver = TestBed.inject(ResourcePoolsResolver);
});
it('should be created', () => {
expect(resolver).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import {
Router, Resolve,
RouterStateSnapshot,
ActivatedRouteSnapshot
} from '@angular/router';
import {Observable, of, Subscriber} from 'rxjs';
import {ControllerService} from "@services/controller.service";
import {ResourcePoolsService} from "@services/resource-pools.service";
import {ResourcePool} from "@models/resourcePools/ResourcePool";
import {Controller} from "@models/controller";
@Injectable({
providedIn: 'root'
})
export class ResourcePoolsResolver implements Resolve<ResourcePool> {
constructor(private controllerService: ControllerService,
private resourcePoolsService: ResourcePoolsService,
) {
}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<ResourcePool> {
return new Observable<ResourcePool>((subscriber: Subscriber<ResourcePool>) => {
const controllerId = route.paramMap.get('controller_id');
const poolId = route.paramMap.get('pool_id');
this.controllerService.get(+controllerId).then((controller: Controller) => {
this.resourcePoolsService.get(controller, poolId).subscribe((resourcePool: ResourcePool) => {
subscriber.next(resourcePool);
subscriber.complete();
});
});
});
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AclService } from './acl.service';
describe('AclService', () => {
let service: AclService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AclService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,53 @@
/*
* Software Name : GNS3 Web UI
* Version: 3
* SPDX-FileCopyrightText: Copyright (c) 2022 Orange Business Services
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This software is distributed under the GPL-3.0 or any later version,
* the text of which is available at https://www.gnu.org/licenses/gpl-3.0.txt
* or see the "LICENSE" file for more details.
*
* Author: Sylvain MATHIEU, Elise LEBEAU
*/
import { Injectable } from '@angular/core';
import {Controller} from "@models/controller";
import {Observable} from "rxjs";
import {HttpController} from "@services/http-controller.service";
import {ACE} from "@models/api/ACE";
import {Endpoint} from "@models/api/endpoint";
@Injectable({
providedIn: 'root'
})
export class AclService {
constructor(
private httpController: HttpController
) {}
getEndpoints(controller: Controller) {
return this.httpController.get<Endpoint[]>(controller, '/access/acl/endpoints')
}
list(controller: Controller) {
return this.httpController.get<ACE[]>(controller, '/access/acl');
}
add(controller: Controller, ace: any): Observable<ACE> {
return this.httpController.post<ACE>(controller, `/access/acl`, ace);
}
get(controller: Controller, ace_id: string) {
return this.httpController.get<ACE>(controller, `/access/acl/${ace_id}`);
}
delete(controller: Controller, ace_id: string) {
return this.httpController.delete(controller, `/access/acl/${ace_id}`);
}
update(controller: Controller, ace: ACE): Observable<ACE> {
return this.httpController.put<ACE>(controller, `/access/acl/${ace.ace_id}`, ace);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ResourcePoolsService } from './resource-pools.service';
describe('ResourcePoolsService', () => {
let service: ResourcePoolsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ResourcePoolsService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,88 @@
import {Injectable} from '@angular/core';
import {Controller} from "@models/controller";
import {Observable, of} from "rxjs";
import {ResourcePool} from "@models/resourcePools/ResourcePool";
import {HttpController} from "@services/http-controller.service";
import {Resource} from "@models/resourcePools/Resource";
import {filter, map, mergeAll, switchMap, tap} from "rxjs/operators";
import {Project} from "@models/project";
import {ProjectService} from "@services/project.service";
@Injectable({
providedIn: 'root'
})
export class ResourcePoolsService {
constructor(private httpController: HttpController,
private projectService: ProjectService) {
}
getAll(controller: Controller) {
return this.httpController.get<ResourcePool[]>(controller, '/pools');
}
get(controller: Controller, poolId: string): Observable<ResourcePool> {
return Observable.forkJoin([
this.httpController.get<ResourcePool>(controller, `/pools/${poolId}`),
this.httpController.get<Resource[]>(controller, `/pools/${poolId}/resources`),
]).pipe(map(results => {
results[0].resources = results[1];
return results[0];
}));
}
delete(controller: Controller, uuid: string) {
return this.httpController.delete(controller, `/pools/${uuid}`);
}
add(controller: Controller, newPoolName: string) {
return this.httpController.post<{ name: string }>(controller, '/pools', {name: newPoolName});
}
update(controller: Controller, pool: ResourcePool) {
return this.httpController.put(controller, `/pools/${pool.resource_pool_id}`, {name: pool.name});
}
addResource(controller: Controller, pool: ResourcePool, project: Project) {
return this.httpController.put<string>(controller, `/pools/${pool.resource_pool_id}/resources/${project.project_id}`, {});
}
deleteResource(controller: Controller, resource: Resource, pool: ResourcePool) {
return this.httpController.delete<string>(controller, `/pools/${pool.resource_pool_id}/resources/${resource.resource_id}`);
}
getFreeResources(controller: Controller) {
return this.projectService
.list(controller)
.pipe(
switchMap((projects) => {
return this.getAllNonFreeResources(controller)
.pipe(map(resources => resources.map(resource => resource.resource_id)),
map(resources_id => projects.filter(project => !resources_id.includes(project.project_id)))
)
}));
}
private getAllNonFreeResources(controller: Controller) {
return this.getAll(controller)
.pipe(switchMap((resourcesPools) => {
return Observable.forkJoin(
resourcesPools.map((r) => this.httpController.get<Resource[]>(controller, `/pools/${r.resource_pool_id}/resources`),)
)
}),
map((data) => {
//flatten results
const output: Resource[] = [];
for(const res of data) {
for(const r of res) {
output.push(r);
}
}
return output;
}));
}
}

View File

@ -27,6 +27,7 @@ export class ToasterService {
constructor(private snackbar: MatSnackBar, private zone: NgZone) {}
public error(message: string) {
console.error(message);
this.zone.run(() => {
this.snackbar.open(message, 'Close', this.snackBarConfigForError);
});