develop #12

Merged
maranda merged 29 commits from develop into main 2025-02-04 09:53:50 +01:00
65 changed files with 1909 additions and 1392 deletions

View File

@ -31,7 +31,7 @@ import {
} from "./components/groups/components/client-main-view/partition-assistant/partition-assistant.component";
import {RepositoriesComponent} from "./components/repositories/repositories.component";
import {
CreateImageComponent
CreateClientImageComponent
} from "./components/groups/components/client-main-view/create-image/create-image.component";
import {
DeployImageComponent
@ -63,10 +63,10 @@ const routes: Routes = [
{ path: 'commands-task', component: CommandsTaskComponent },
{ path: 'commands-logs', component: TaskLogsComponent },
{ path: 'calendars', component: CalendarComponent },
{ path: 'clients/deploy-image', component: DeployImageComponent },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent },
{ path: 'clients/:id', component: ClientMainViewComponent },
{ path: 'clients/:id/partition-assistant', component: PartitionAssistantComponent },
{ path: 'clients/:id/create-image', component: CreateImageComponent },
{ path: 'clients/:id/deploy-image', component: DeployImageComponent },
{ path: 'clients/:id/create-image', component: CreateClientImageComponent },
{ path: 'images', component: ImagesComponent },
{ path: 'repositories', component: RepositoriesComponent },
{ path: 'repository/:id', component: MainRepositoryViewComponent },

View File

@ -52,7 +52,6 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { ToastrModule } from 'ngx-toastr';
import { ShowOrganizationalUnitComponent } from './components/groups/shared/organizational-units/show-organizational-unit/show-organizational-unit.component';
import { MatGridList, MatGridTile } from "@angular/material/grid-list";
import { TreeViewComponent } from './components/groups/shared/tree-view/tree-view.component';
import {
MatNestedTreeNode,
MatTree,
@ -102,6 +101,7 @@ import {MatSliderModule} from '@angular/material/slider';
import { ClientMainViewComponent } from './components/groups/components/client-main-view/client-main-view.component';
import { ImagesComponent } from './components/images/images.component';
import { CreateImageComponent } from './components/images/create-image/create-image.component';
import { CreateClientImageComponent} from './components/groups/components/client-main-view/create-image/create-image.component';
import { PartitionAssistantComponent } from './components/groups/components/client-main-view/partition-assistant/partition-assistant.component';
import { SoftwareComponent } from './components/software/software.component';
import { CreateSoftwareComponent } from './components/software/create-software/create-software.component';
@ -124,6 +124,9 @@ import { MatSortModule } from '@angular/material/sort';
import { MenusComponent } from './components/menus/menus.component';
import { CreateMenuComponent } from './components/menus/create-menu/create-menu.component';
import { CreateMultipleClientComponent } from './components/groups/shared/clients/create-multiple-client/create-multiple-client.component';
import { ExportImageComponent } from './components/images/export-image/export-image.component';
import {ImportImageComponent} from "./components/repositories/import-image/import-image.component";
import { LoadingComponent } from './shared/loading/loading.component';
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './locale/', '.json');
}
@ -152,7 +155,6 @@ export function HttpLoaderFactory(http: HttpClient) {
ClassroomViewComponent,
ClientViewComponent,
ShowOrganizationalUnitComponent,
TreeViewComponent,
LegendComponent,
ClassroomViewDialogComponent,
SaveFiltersDialogComponent,
@ -174,6 +176,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CreateCommandComponent,
CalendarComponent,
CreateCalendarComponent,
CreateClientImageComponent,
CreateCalendarRuleComponent,
CommandsGroupsComponent,
CommandsTaskComponent,
@ -205,7 +208,10 @@ export function HttpLoaderFactory(http: HttpClient) {
EnvVarsComponent,
MenusComponent,
CreateMenuComponent,
CreateMultipleClientComponent
CreateMultipleClientComponent,
ExportImageComponent,
ImportImageComponent,
LoadingComponent,
],
bootstrap: [AppComponent],
imports: [BrowserModule,

View File

@ -90,3 +90,10 @@ table {
color: white;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}

View File

@ -2,7 +2,11 @@
<button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon>
</button>
<h2 class="title" joyrideStep="titleStep" text="{{ 'titleStepText' | translate }}">{{ 'adminCommandsTitle' | translate }}</h2>
<div class="header-container-title">
<h2 class="title" joyrideStep="titleStep" text="{{ 'titleStepText' | translate }}">{{ 'adminCommandsTitle' | translate }}</h2>
</div>
<div class="images-button-row">
<button mat-flat-button color="primary" (click)="resetFilters()" joyrideStep="resetFiltersStep" text="{{ 'resetFiltersStepText' | translate }}">
{{ 'resetFilters' | translate }}

View File

@ -1,10 +1,20 @@
<button mat-icon-button color="primary" [matMenuTriggerFor]="commandMenu">
<mat-icon>terminal</mat-icon>
</button>
<ng-container [ngSwitch]="buttonType">
<button *ngSwitchCase="'icon'" mat-icon-button color="primary" [matMenuTriggerFor]="commandMenu">
<mat-icon>{{ icon }}</mat-icon>
</button>
<mat-menu #commandMenu="matMenu">
<button mat-menu-item [disabled]="command.disabled" *ngFor="let command of arrayCommands" (click)="onCommandSelect(command.slug)">
<button mat-flat-button [disabled]="clientData.length === 0" *ngSwitchCase="'text'" mat-button color="primary" [matMenuTriggerFor]="commandMenu">
{{ buttonText }}
</button>
</ng-container>
<mat-menu #commandMenu="matMenu" >
<button
mat-menu-item
[disabled]="command.disabled || (command.slug === 'create-image' && clientData.length > 1)"
*ngFor="let command of arrayCommands"
(click)="onCommandSelect(command.slug)"
>
{{ command.name }}
</button>
</mat-menu>

View File

@ -1,4 +1,4 @@
import {Component, Inject, Input, OnInit} from '@angular/core';
import {Component, Inject, Input, OnInit, SimpleChanges} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog';
import { HttpClient } from '@angular/common/http';
import { FormBuilder, FormGroup } from '@angular/forms';
@ -11,7 +11,10 @@ import {ToastrService} from "ngx-toastr";
styleUrls: ['./execute-command.component.css']
})
export class ExecuteCommandComponent implements OnInit {
@Input() clientData: any = {};
@Input() clientData: any[] = [];
@Input() buttonType: 'icon' | 'text' = 'icon';
@Input() buttonText: string = 'Ejecutar Comandos';
@Input() icon: string = 'terminal';
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
loading: boolean = true;
@ -29,6 +32,8 @@ export class ExecuteCommandComponent implements OnInit {
{name: 'Ejecutar script', slug: 'run-script', disabled: true},
];
client: any = {};
constructor(
private dialog: MatDialog,
private http: HttpClient,
@ -39,20 +44,13 @@ export class ExecuteCommandComponent implements OnInit {
}
ngOnInit(): void {
this.clientData = this.clientData || {};
this.loadClient(this.clientData)
this.clientData = this.clientData || [];
}
loadClient = (uuid: string) => {
this.http.get<any>(`${this.baseUrl}${uuid}`).subscribe({
next: data => {
this.clientData = data;
this.loading = false;
},
error: error => {
console.error('Error al obtener el cliente:', error);
}
});
ngOnChanges(changes: SimpleChanges): void {
if (changes['clientData']) {
console.log(this.clientData.length)
}
}
onCommandSelect(action: any): void {
@ -82,7 +80,9 @@ export class ExecuteCommandComponent implements OnInit {
}
rebootClient(): void {
this.http.post(`${this.baseUrl}/clients/server/${this.clientData.uuid}/reboot`, {}).subscribe(
this.http.post(`${this.baseUrl}/clients/server/reboot`, {
clients: this.clientData.map((client: any) => client['@id'])
}).subscribe(
response => {
this.toastService.success('Cliente actualizado correctamente');
},
@ -93,13 +93,11 @@ export class ExecuteCommandComponent implements OnInit {
}
powerOnClient(): void {
const payload = {
client: this.clientData['@id']
}
this.http.post(`${this.baseUrl}${this.clientData.repository['@id']}/wol`, payload).subscribe(
this.http.post(`${this.baseUrl}/image-repositories/wol`, {
clients: this.clientData.map((client: any) => client['@id'])
}).subscribe(
response => {
this.toastService.success('Cliente actualizado correctamente');
this.toastService.success('Petición de encendido enviada correctamente');
},
error => {
this.toastService.error('Error de conexión con el cliente');
@ -108,9 +106,11 @@ export class ExecuteCommandComponent implements OnInit {
}
powerOffClient(): void {
this.http.post(`${this.baseUrl}/clients/server/${this.clientData.uuid}/power-off`, {}).subscribe(
this.http.post(`${this.baseUrl}/clients/server/power-off`, {
clients: this.clientData.map((client: any) => client['@id'])
}).subscribe(
response => {
this.toastService.success('Cliente actualizado correctamente');
this.toastService.success('Petición de apagado enviada correctamente');
},
error => {
this.toastService.error('Error de conexión con el cliente');
@ -119,21 +119,24 @@ export class ExecuteCommandComponent implements OnInit {
}
openPartitionAssistant(): void {
this.router.navigate([`/clients/${this.clientData.uuid}/partition-assistant`]).then(r => {
console.log('navigated', r);
this.router.navigate(['/clients/partition-assistant'], {
state: { clientData: this.clientData },
}).then(r => {
console.log('Navigated to partition assistant with data:', this.clientData);
});
}
openCreateImageAssistant(): void {
this.router.navigate([`/clients/${this.clientData.uuid}/create-image`]).then(r => {
this.router.navigate([`/clients/${this.clientData[0].uuid}/create-image`]).then(r => {
console.log('navigated', r);
});
}
openDeployImageAssistant(): void {
this.router.navigate([`/clients/${this.clientData.uuid}/deploy-image`]).then(r => {
console.log('navigated', r);
this.router.navigate(['/clients/deploy-image'], {
state: { clientData: this.clientData },
}).then(r => {
console.log('Navigated to deploy image with data:', this.clientData);
});
}
}

View File

@ -302,7 +302,7 @@ export class ClientMainViewComponent implements OnInit {
}
openDeployImageAssistant(): void {
this.router.navigate([`/clients/${this.clientData.uuid}/deploy-image`]).then(r => {
this.router.navigate([`/clients/deploy-image`]).then(r => {
console.log('navigated', r);
});
}

View File

@ -1,8 +1,13 @@
<app-loading [isLoading]="loading"></app-loading>
<div class="header-container">
<button mat-flat-button color="primary" (click)="back()">Volver</button>
<h2 class="title" i18n="@@subnetsTitle">Crear Imagen desde {{ clientName }}</h2>
<div class="header-container-title">
<h2 >
Crear imagen desde {{ clientName }}
</h2>
</div>
<div class="subnets-button-row">
<button mat-flat-button color="primary" (click)="save()">Guardar y ejecutar</button>
<button mat-flat-button color="primary" (click)="save()">Ejecutar</button>
</div>
</div>
<mat-divider></mat-divider>
@ -12,14 +17,6 @@
<mat-label>Nombre canónico</mat-label>
<input matInput [(ngModel)]="name" placeholder="Nombre canónico. En minúscula y sin espacios" required>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione imagen creada previamente</mat-label>
<mat-select [(ngModel)]="selectedImage">
<mat-option>--</mat-option>
<mat-option *ngFor="let image of images" [value]="image['@id']">{{ image.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">

View File

@ -1,77 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateImageComponent } from './create-image.component';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ToastrModule, ToastrService } from 'ngx-toastr';
import { of } from 'rxjs';
describe('CreateImageComponent', () => {
let component: CreateImageComponent;
let fixture: ComponentFixture<CreateImageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
CreateImageComponent,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
MatTabsModule,
MatTableModule,
MatPaginatorModule,
BrowserAnimationsModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
],
providers: [
FormBuilder,
ToastrService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {}
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: {
get: (key: string) => 'valorSimulado'
}
},
params: of({ id: 'valorSimulado' })
}
}
]
})
.compileComponents();
fixture = TestBed.createComponent(CreateImageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -2,61 +2,14 @@ import {Component, EventEmitter, Output} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {ToastrService} from "ngx-toastr";
import {ActivatedRoute, Router} from "@angular/router";
import {MatButton} from "@angular/material/button";
import {MatDivider} from "@angular/material/divider";
import {NgForOf, NgIf} from "@angular/common";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {
MatCell, MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef,
MatTable,
MatTableDataSource
} from "@angular/material/table";
import {MatChip} from "@angular/material/chips";
import {MatCheckbox} from "@angular/material/checkbox";
import {MatTableDataSource} from "@angular/material/table";
import {SelectionModel} from "@angular/cdk/collections";
import {MatRadioButton, MatRadioGroup} from "@angular/material/radio";
import {MatFormField, MatLabel} from "@angular/material/form-field";
import {MatOption} from "@angular/material/autocomplete";
import {MatSelect} from "@angular/material/select";
import {MatInput} from "@angular/material/input";
@Component({
selector: 'app-create-image',
templateUrl: './create-image.component.html',
standalone: true,
imports: [
MatButton,
MatDivider,
NgForOf,
NgIf,
ReactiveFormsModule,
MatTable,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatCell,
MatCellDef,
MatChip,
MatHeaderRow,
MatRow,
MatHeaderRowDef,
MatRowDef,
MatCheckbox,
MatRadioGroup,
MatRadioButton,
MatFormField,
MatLabel,
MatOption,
MatSelect,
MatInput,
FormsModule
],
styleUrl: './create-image.component.css'
})
export class CreateImageComponent {
export class CreateClientImageComponent {
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
@Output() dataChange = new EventEmitter<any>();
@ -65,10 +18,10 @@ export class CreateImageComponent {
partitions: any[] = [];
images: any[] = [];
clientName: string = '';
selectedImage: string | null = null;
selectedPartition: any = null;
name: string = '';
client: any = null;
loading: boolean = false;
dataSource = new MatTableDataSource<any>();
columns = [
{
@ -111,7 +64,6 @@ export class CreateImageComponent {
ngOnInit() {
this.clientId = this.route.snapshot.paramMap.get('id');
this.loadPartitions();
this.loadImages();
}
@ -147,15 +99,12 @@ export class CreateImageComponent {
);
}
back() {
this.router.navigate(['clients', this.clientId], { state: { clientData: this.client} });
}
save(): void {
this.loading = true;
const payload = {
client: `/clients/${this.clientId}`,
name: this.name,
image: this.selectedImage,
partition: this.selectedPartition['@id'],
source: 'assistant'
};
@ -165,11 +114,12 @@ export class CreateImageComponent {
.subscribe({
next: (response) => {
this.toastService.success('Petición de creación de imagen enviada');
this.loading = false;
this.router.navigate(['/images']);
},
error: (error) => {
console.error('Error:', error);
this.toastService.error(error.error['hydra:description']);
this.loading = false;
}
}
);

View File

@ -37,6 +37,7 @@ table {
width: 100%;
padding: 0 5px;
box-sizing: border-box;
padding-left: 1em;
}
.input-group {
@ -70,7 +71,6 @@ table {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
}
.mat-elevation-z8 {
@ -82,3 +82,47 @@ table {
justify-content: end;
margin-bottom: 30px;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-item {
position: relative;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
}
.client-details {
margin-top: 4px;
}
.client-name {
display: block;
font-size: 1.2em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}

View File

@ -1,12 +1,45 @@
<app-loading [isLoading]="loading"></app-loading>
<div class="header-container">
<button mat-flat-button color="primary" (click)="back()">Volver</button>
<h2 class="title" i18n="@@subnetsTitle">Desplegar imagen en {{ clientName }}</h2>
<div class="header-container-title">
<h2>
{{ 'deployImage' | translate }}
</h2>
</div>
<div class="subnets-button-row">
<button mat-flat-button color="primary" (click)="save()">Guardar</button>
<button mat-flat-button color="primary" (click)="save()">Ejecutar</button>
</div>
</div>
<mat-divider></mat-divider>
<div class="select-container">
<mat-expansion-panel hideToggle>
<mat-expansion-panel-header>
<mat-panel-title> Clientes </mat-panel-title>
<mat-panel-description> Listado de clientes donde se desplegará la imagen </mat-panel-description>
</mat-expansion-panel-header>
<div class="clients-grid" >
<div *ngFor="let client of clientData" class="client-item">
<div class="client-card">
<img
[src]="'assets/images/ordenador_' + client.status + '.png'"
alt="Client Icon"
class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
</div>
</div>
</div>
</mat-expansion-panel>
</div>
<mat-divider style="margin-top: 20px;"></mat-divider>
<div class="select-container">
<div class="option-container">
<mat-radio-group [(ngModel)]="selectedOption" name="selectedOption" aria-label="Selecciona una opcion">
@ -84,12 +117,12 @@
<mat-form-field appearance="fill" class="input-field">
<mat-label>Máximo Clientes</mat-label>
<input matInput [(ngModel)]="mcastMaxClients" name="mcastMaxClients">
<input matInput [(ngModel)]="mcastMaxClients" name="mcastMaxClients" type="number">
</mat-form-field>
<mat-form-field appearance="fill" class="input-field">
<mat-label>Tiempo Máximo de Espera</mat-label>
<input matInput [(ngModel)]="mcastMaxTime" name="mcastMaxTime">
<input matInput [(ngModel)]="mcastMaxTime" name="mcastMaxTime" type="number">
</mat-form-field>
</div>

View File

@ -17,6 +17,8 @@ import { TranslateModule } from '@ngx-translate/core';
import { ToastrModule, ToastrService } from 'ngx-toastr';
import { provideRouter } from '@angular/router';
import { MatSelectModule } from '@angular/material/select';
import {MatExpansionModule} from "@angular/material/expansion";
import {LoadingComponent} from "../../../../../shared/loading/loading.component";
describe('DeployImageComponent', () => {
let component: DeployImageComponent;
@ -24,7 +26,7 @@ describe('DeployImageComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DeployImageComponent],
declarations: [DeployImageComponent, LoadingComponent],
imports: [
ReactiveFormsModule,
FormsModule,
@ -32,6 +34,7 @@ describe('DeployImageComponent', () => {
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatExpansionModule,
MatButtonModule,
MatTableModule,
MatDividerModule,

View File

@ -1,4 +1,4 @@
import {Component, EventEmitter, Output} from '@angular/core';
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {SelectionModel} from "@angular/cdk/collections";
import {HttpClient} from "@angular/common/http";
@ -33,11 +33,13 @@ export class DeployImageComponent {
p2pTime: Number = 0;
name: string = '';
client: any = null;
clientData: any = [];
loading: boolean = false;
protected p2pModeOptions = [
{ name: 'Leecher', value: 'p2p-mode-leecher' },
{ name: 'Peer', value: 'p2p-mode-peer' },
{ name: 'Seeder', value: 'p2p-mode-seeder' },
{ name: 'Leecher', value: 'leecher' },
{ name: 'Peer', value: 'peer' },
{ name: 'Seeder', value: 'seeder' },
];
protected multicastModeOptions = [
{ name: 'Half duplex', value: "half"},
@ -47,7 +49,6 @@ export class DeployImageComponent {
allMethods = [
'uftp',
'udpcast',
'multicast-direct',
'unicast',
'unicast-direct',
'p2p'
@ -56,7 +57,6 @@ export class DeployImageComponent {
updateCacheMethods = [
'uftp',
'udpcast',
'multicast',
'unicast',
'p2p'
];
@ -98,13 +98,12 @@ export class DeployImageComponent {
private toastService: ToastrService,
private route: ActivatedRoute,
private router: Router,
) {}
ngOnInit() {
this.clientId = this.route.snapshot.paramMap.get('id');
this.selectedOption = 'deploy-image';
this.loadPartitions();
) {
const navigation = this.router.getCurrentNavigation();
this.clientData = navigation?.extras?.state?.['clientData'];
this.clientId = this.clientData?.[0]['@id'];
this.loadImages();
this.loadPartitions()
}
get deployMethods() {
@ -116,7 +115,7 @@ export class DeployImageComponent {
}
loadPartitions() {
const url = `${this.baseUrl}/clients/${this.clientId}`;
const url = `${this.baseUrl}${this.clientId}`;
this.http.get(url).subscribe(
(response: any) => {
if (response.partitions) {
@ -151,11 +150,9 @@ export class DeployImageComponent {
);
}
back() {
this.router.navigate(['clients', this.clientId], { state: { clientData: this.client} });
}
save(): void {
this.loading = true;
if (!this.selectedImage) {
this.toastService.error('Debe seleccionar una imagen');
return;
@ -171,26 +168,44 @@ export class DeployImageComponent {
return;
}
this.toastService.info('Preparando petición de despliegue');
const payload = {
client: `/clients/${this.clientId}`,
clients: this.clientData.map((client: any) => client['@id']),
method: this.selectedMethod,
partition: this.selectedPartition['@id'],
// partition: this.selectedPartition['@id'],
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.p2pMode,
p2pTime: this.p2pTime,
mcastIp: this.mcastIp,
mcastPort: this.mcastPort,
mcastMode: this.mcastMode,
mcastSpeed: this.mcastSpeed,
maxTime: this.mcastMaxTime,
maxClients: this.mcastMaxClients,
};
this.http.post(`${this.baseUrl}${this.selectedImage}/deploy-image`, payload)
.subscribe({
next: (response) => {
this.toastService.success('Petición de despliegue enviada correctamente');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
console.error('Error:', error);
this.toastService.error(error.error['hydra:description']);
this.toastService.error(error.error['hydra:description'], 'Se ha detectado un error en el despliegue de imágenes.', {
"closeButton": true,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-bottom-right",
"timeOut": 0,
"extendedTimeOut": 0,
"tapToDismiss": false
});
this.loading = false;
}
}
);

View File

@ -167,3 +167,58 @@ button.remove-btn:hover {
padding: 20px;
margin: 10px auto;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-item {
position: relative;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
}
.client-details {
margin-top: 4px;
}
.client-name {
display: block;
font-size: 1.2em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.select-container {
margin-top: 20px;
align-items: center;
width: 100%;
padding: 0 5px;
box-sizing: border-box;
padding-left: 1em;
}

View File

@ -1,12 +1,45 @@
<app-loading [isLoading]="loading"></app-loading>
<div class="header-container">
<button mat-flat-button color="primary" (click)="back()">Volver</button>
<h2 class="title" i18n="@@subnetsTitle">Asistente de particionado</h2>
<div class="header-container-title">
<h2 joyrideStep="groupsTitleStepText" text="{{ 'groupsTitleStepText' | translate }}">
Asistente de particionado
</h2>
</div>
<div class="subnets-button-row">
<button mat-flat-button color="primary" [disabled]="data.status === 'busy'" (click)="save()">Ejecutar</button>
</div>
</div>
<mat-divider></mat-divider>
<div class="select-container">
<mat-expansion-panel hideToggle>
<mat-expansion-panel-header>
<mat-panel-title> Clientes </mat-panel-title>
<mat-panel-description> Listado de clientes donde se realizará el particionado </mat-panel-description>
</mat-expansion-panel-header>
<div class="clients-grid" >
<div *ngFor="let client of clientData" class="client-item">
<div class="client-card">
<img
[src]="'assets/images/ordenador_' + client.status + '.png'"
alt="Client Icon"
class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
</div>
</div>
</div>
</mat-expansion-panel>
</div>
<mat-divider style="margin-top: 20px;"></mat-divider>
<mat-dialog-content>
<div class="disk-select">
<mat-form-field appearance="fill">

View File

@ -26,7 +26,7 @@ interface Partition {
templateUrl: './partition-assistant.component.html',
styleUrls: ['./partition-assistant.component.css']
})
export class PartitionAssistantComponent implements OnInit {
export class PartitionAssistantComponent {
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
@Output() dataChange = new EventEmitter<any>();
partitionTypes = PARTITION_TYPES;
@ -39,6 +39,8 @@ export class PartitionAssistantComponent implements OnInit {
updateRequests: any[] = [];
data: any = {};
disks: { diskNumber: number; totalDiskSize: number; partitions: Partition[]; chartData: any[]; used: number; percentage: number }[] = [];
clientData: any = [];
loading: boolean = false;
private apiUrl: string = this.baseUrl + '/partitions';
@ -51,11 +53,12 @@ export class PartitionAssistantComponent implements OnInit {
private toastService: ToastrService,
private route: ActivatedRoute,
private router: Router,
) {}
ngOnInit() {
this.clientId = this.route.snapshot.paramMap.get('id');
) {
const navigation = this.router.getCurrentNavigation();
this.clientData = navigation?.extras?.state?.['clientData'];
this.clientId = this.clientData[0]['@id'];
this.loadPartitions();
}
get selectedDisk():any {
@ -63,7 +66,7 @@ export class PartitionAssistantComponent implements OnInit {
}
loadPartitions() {
const url = `${this.baseUrl}/clients/${this.clientId}`;
const url = `${this.baseUrl}${this.clientId}`;
this.http.get(url).subscribe(
(response) => {
this.data = response;
@ -250,16 +253,14 @@ export class PartitionAssistantComponent implements OnInit {
return modifiedPartitions;
}
back() {
this.router.navigate(['clients', this.data.uuid], { state: { clientData: this.data } });
}
save() {
if (!this.selectedDisk) {
this.errorMessage = 'Por favor selecciona un disco antes de guardar.';
return;
}
this.loading = true;
const totalPartitionSize = this.selectedDisk.partitions.reduce((sum: any, partition: { size: any; }) => sum + partition.size, 0);
if (totalPartitionSize > this.selectedDisk.totalDiskSize) {
@ -283,22 +284,26 @@ export class PartitionAssistantComponent implements OnInit {
size: partition.size,
partitionCode: partition.partitionCode,
filesystem: partition.filesystem,
client: `/clients/${this.clientId}`,
uuid: partition.uuid,
removed: partition.removed || false,
format: partition.format || false,
}));
if (newPartitions.length > 0) {
const bulkPayload = { partitions: newPartitions };
const bulkPayload = {
partitions: newPartitions,
clients: this.clientData.map((client: any) => client['@id']),
};
this.http.post(this.apiUrl, bulkPayload).subscribe(
(response) => {
this.toastService.success('Particiones creadas exitosamente para el disco seleccionado.');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
(error) => {
console.error('Error al crear las particiones:', error);
this.loading = false;
this.toastService.error('Error al crear las particiones.');
}
);

View File

@ -1,39 +0,0 @@
<h2>{{ 'diskImageAssistantTitle' | translate }}</h2>
<div *ngFor="let disk of disks" class="partition-assistant">
<div class="header">
<label>{{ 'diskLabel' | translate }} {{ disk.diskNumber }}</label>
</div>
<table class="partition-table">
<thead>
<tr>
<th>{{ 'partitionColumn' | translate }}</th>
<th>{{ 'isoImageColumn' | translate }}</th>
<th>{{ 'ogliveColumn' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let partition of disk.partitions">
<td>{{ partition.partitionNumber }}</td>
<td>
<select [(ngModel)]="partition.associatedImageId" (change)="onImageSelected(partition, $event)" name="associatedImage-{{partition.partitionNumber}}">
<option value="">{{ 'selectImageOption' | translate }}</option>
<option *ngFor="let image of availableImages" [value]="image['@id']">
{{ image.name }}
</option>
</select>
</td>
<td>
<select (change)="onOgLiveSelected(partition, $event)">
<option value="">{{ 'selectOgLiveOption' | translate }}</option>
<option *ngFor="let ogLive of availableOgLives" [value]="ogLive">{{ ogLive }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div class="actions">
<button mat-flat-button color="primary" (click)="saveAssociations()">{{ 'saveAssociationsButton' | translate }}</button>
</div>

View File

@ -3,8 +3,7 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background-color: #f5f5f5;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
@ -33,58 +32,6 @@ button[mat-raised-button] {
font-size: 16px;
}
mat-card {
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
align-items: center;
}
mat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
}
.unidad-card {
cursor: pointer;
padding: 16px;
font-size: 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.unidad-card.selected-item {
border: 2px solid #1976d2;
}
mat-card-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
color: #333;
margin: 0;
}
mat-card-title mat-icon {
font-size: 20px;
margin-right: 8px;
color: #1976d2;
}
mat-card-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.actions mat-icon {
color: #757575;
cursor: pointer;
@ -186,7 +133,7 @@ button[mat-raised-button] {
mat-tree {
background-color: #f9f9f9;
padding: 10px;
padding: 0px 10px 10px 10px;
}
mat-tree mat-tree-node {
@ -298,18 +245,14 @@ mat-tree mat-tree-node.disabled:hover {
.filters-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 16px;
justify-content: start;
gap: 1rem;
margin: 2rem 0px 0.7rem 10px;
}
.filters-container mat-form-field {
flex: 1 1 100%;
max-width: 300px;
}
.filter-container {
margin-bottom: 16px;
max-width: 250px;
}
.chip-busy {
@ -394,24 +337,17 @@ mat-tree mat-tree-node.disabled:hover {
.tree-container {
width: 25%;
padding: 16px;
overflow-x: hidden;
overflow-y: auto;
}
.clients-container {
width: 75%;
padding: 16px;
padding: 0px 16px 16px 16px;
box-sizing: border-box;
overflow-y: auto;
}
.clients-container h3 {
margin-bottom: 15px;
font-size: 1.5em;
color: #333;
}
.client-item {
display: flex;
justify-content: center;
@ -516,23 +452,10 @@ button[mat-raised-button] {
flex-shrink: 0;
}
.filters-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 16px 0;
padding: 0 16px;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.filters-container mat-form-field {
flex: 1 1 300px;
max-width: 300px;
}
.client-info {
display: flex;
flex-direction: column;
@ -586,7 +509,7 @@ button[mat-raised-button] {
.clients-title-name {
font-size: x-large;
display: block;
padding: 1rem 1rem 1rem 15px;
padding: 1rem 1rem 1rem 13px;
}
.no-clients-info {
@ -594,4 +517,5 @@ button[mat-raised-button] {
align-items: center;
gap: 10px;
margin-top: 1.5rem;
margin-left: 16px;
}

View File

@ -15,8 +15,8 @@
</button>
<button mat-flat-button color="primary" [matMenuTriggerFor]="menuClients">{{ 'newClientButton' | translate }}</button>
<mat-menu #menuClients="matMenu">
<button mat-menu-item (click)="addClient($event)" >Añadir cliente unitario</button>
<button mat-menu-item (click)="addMultipleClients($event)">Añadir clientes masivamente</button>
<button mat-menu-item (click)="addClient($event)">{{ 'newSingleClientButton' | translate }}</button>
<button mat-menu-item (click)="addMultipleClients($event)">{{ 'newMultipleClientButton' | translate }}</button>
</mat-menu>
<button mat-flat-button (click)="openBottomSheet()" joyrideStep="keyStep" text="{{ 'keyStepText' | translate }}"
@ -27,26 +27,43 @@
</div>
<!-- Filters Panel -->
<mat-expansion-panel *ngIf="isTreeViewActive" class="filters-panel" joyrideStep="filtersPanelStep" text="{{ 'filtersPanelStepText' | translate }}">
<mat-expansion-panel-header>
<mat-panel-title>{{ 'filters' | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<div class="filters-panel" joyrideStep="filtersPanelStep" text="{{ 'filtersPanelStepText' | translate }}">
<div class="filters-container">
<mat-form-field appearance="outline">
<mat-label>{{ 'searchClient' | translate }}</mat-label>
<input matInput (input)="onClientFilterInput($event)" placeholder="Buscar nombre, IP, estado o MAC">
</mat-form-field>
<mat-label>{{ 'searchTree' | translate }}</mat-label>
<input matInput #treeSearchInput (input)="onTreeFilterInput($event)" placeholder="Centro, aula, grupos ..." />
<button
*ngIf="treeSearchInput.value"
mat-icon-button
matSuffix
aria-label="Clear tree search"
(click)="clearTreeSearch(treeSearchInput)"
>
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'searchClient' | translate }}</mat-label>
<input matInput #clientSearchInput (input)="onClientFilterInput($event)" placeholder="Nombre, IP, estado o MAC" />
<button
*ngIf="clientSearchInput.value"
mat-icon-button
matSuffix
aria-label="Clear client search"
(click)="clearClientSearch(clientSearchInput)"
>
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<!-- Funcionalidad actualmente deshabilitada-->
<!-- <mat-form-field appearance="outline">
<mat-select (selectionChange)="loadSelectedFilter($event.value)" placeholder="Cargar filtro" disabled>
<mat-option *ngFor="let savedFilter of savedFilterNames" [value]="savedFilter">
{{ savedFilter[0] }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'searchTree' | translate }}</mat-label>
<input matInput (input)="onTreeFilterInput($event)" placeholder="Buscar nombre o tipo" disabled>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'filterByType' | translate }}</mat-label>
<mat-select [(value)]="selectedTreeFilter" (selectionChange)="filterTree(searchTerm, $event.value)" disabled>
@ -55,16 +72,16 @@
<mat-option value="classroom">{{ 'classrooms' | translate }}</mat-option>
<mat-option value="group">{{ 'computerGroups' | translate }}</mat-option>
</mat-select>
</mat-form-field>
</mat-form-field> -->
</div>
</mat-expansion-panel>
</div>
<!-- Unit details view-->
<div class="main-container">
<!-- Tree view -->
<div class="tree-container">
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl">
<mat-tree-node [ngClass]="{'selected-node': node === selectedNode}" *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding (click)="onNodeClick(node)">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}" *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding (click)="onNodeClick(node)">
<button mat-icon-button matTreeNodeToggle [disabled]="!node.expandable" [ngClass]="{'disabled-toggle': !node.expandable}">
<mat-icon>{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}</mat-icon>
</button>
@ -83,7 +100,7 @@
<mat-icon>more_vert</mat-icon>
</button>
</mat-tree-node>
<mat-tree-node [ngClass]="{'selected-node': node === selectedNode}" *matTreeNodeDef="let node; when: isLeafNode" matTreeNodePadding (click)="onNodeClick(node)">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}" *matTreeNodeDef="let node; when: isLeafNode" matTreeNodePadding (click)="onNodeClick(node)">
<button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle"></button>
<mat-icon style="color: green;">
{{
@ -125,20 +142,20 @@
</button>
<button mat-menu-item (click)="addClient($event, selectedNode)">
<mat-icon>add</mat-icon>
<span>{{ 'addClientMenu' | translate }}</span>
<span>{{ 'newSingleClientButton' | translate }}</span>
</button>
<button mat-menu-item (click)="addMultipleClients($event, selectedNode)">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'newMultipleClientButton' | translate }}</span>
</button>
<button mat-menu-item (click)="addOU($event, selectedNode)">
<mat-icon>playlist_add</mat-icon>
<mat-icon>account_tree</mat-icon>
<span>{{ 'addOrganizationalUnit' | translate }}</span>
</button>
<button mat-menu-item (click)="onEditNode($event, selectedNode)">
<mat-icon>edit</mat-icon>
<span>{{ 'edit' | translate }}</span>
</button>
<button mat-menu-item (click)="selectedNode && onTreeClick($event, selectedNode)">
<mat-icon matTooltip="{{ 'viewTreeTooltip' | translate }}" matTooltipHideDelay="0">account_tree</mat-icon>
<span>{{ 'viewTreeMenu' | translate }}</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, selectedNode)">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
@ -148,10 +165,15 @@
<!-- Clients view -->
<div class="clients-container">
<div class="clients-view-header">
<span class="clients-title-name">{{ 'clients' | translate }}
<span class="clients-title-name">{{ 'clients' | translate }}
<strong>{{ selectedNode?.name }}</strong>
</span>
</span>
<div class="view-type-container">
<app-execute-command
[clientData]="arrayClients"
[buttonType]="'text'"
[buttonText]="'Ejecutar comandos'"
></app-execute-command>
<button mat-button color="primary" (click)="toggleView('card')" [disabled]="currentView === 'card'">
<mat-icon>grid_view</mat-icon> {{ 'Vista Tarjeta' | translate }}
</button>
@ -193,7 +215,11 @@
<button mat-icon-button color="primary" (click)="onShowClientDetail($event, client)">
<mat-icon>visibility</mat-icon>
</button>
<app-execute-command [clientData]="client['@id']"></app-execute-command>
<app-execute-command
[clientData]="[client]"
[buttonType]="'icon'"
[icon]="'terminal'"
></app-execute-command>
</div>
</div>
</div>
@ -202,9 +228,27 @@
<!-- List view -->
<div class="clients-table" *ngIf="currentView === 'list'">
<table mat-table matSort [dataSource]="selectedClients" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="toggleRow(row)"
[checked]="selection.isSelected(row)"
[disabled]="row.status === 'busy'"
>
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'status' | translate }} </th>
<td mat-cell *matCellDef="let client">
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<img
[src]="'assets/images/ordenador_' + client.status + '.png'"
alt="Client Icon"
@ -231,7 +275,8 @@
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'name' | translate }} </th>
<td mat-cell *matCellDef="let client">
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<div class="client-info">
<div class="client-name">{{ client.name }}</div>
<div class="client-ip">{{ client.ip }}</div>
@ -268,7 +313,11 @@
<button mat-icon-button [matMenuTriggerFor]="clientMenu">
<mat-icon>more_vert</mat-icon>
</button>
<app-execute-command [clientData]="client['@id']"></app-execute-command>
<app-execute-command
[clientData]="[client]"
[buttonType]="'icon'"
[icon]="'terminal'"
></app-execute-command>
<mat-menu #clientMenu="matMenu">
<button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)">
<mat-icon>edit</mat-icon>
@ -278,7 +327,7 @@
<mat-icon>visibility</mat-icon>
<span>{{ 'viewDetails' | translate }}</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client, selectedNode)">
<button mat-menu-item (click)="onDeleteClick($event, client)">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
</button>
@ -288,7 +337,7 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator [pageSize]="10" [pageSizeOptions]="[5, 10, 20]" showFirstLastButtons></mat-paginator>
<mat-paginator [pageSize]="10" [pageSizeOptions]="[5, 10, 20, 50]" showFirstLastButtons></mat-paginator>
</div>
</div>
</div>
@ -299,8 +348,8 @@
<mat-spinner></mat-spinner>
</div>
<div *ngIf="!isLoadingClients" class="no-clients-info">
<mat-icon>error_outline</mat-icon>
<span>{{ 'noClients' | translate }}</span>
<mat-icon>error_outline</mat-icon>
</div>
</ng-template>

View File

@ -24,6 +24,8 @@ import { TranslateModule } from '@ngx-translate/core';
import { JoyrideModule } from 'ngx-joyride';
import { MatMenuModule } from '@angular/material/menu';
import { MatTreeModule } from '@angular/material/tree';
import { TreeNode } from './model/model';
import {ExecuteCommandComponent} from "../commands/main-commands/execute-command/execute-command.component";
describe('GroupsComponent', () => {
let component: GroupsComponent;
@ -31,7 +33,7 @@ describe('GroupsComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [GroupsComponent],
declarations: [GroupsComponent, ExecuteCommandComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),
@ -80,21 +82,43 @@ describe('GroupsComponent', () => {
expect(component.search).toHaveBeenCalled();
});
it('should call getFilters on ngOnInit', () => {
spyOn(component, 'getFilters');
component.ngOnInit();
expect(component.getFilters).toHaveBeenCalled();
});
it('should call search method', () => {
spyOn(component, 'search');
component.search();
expect(component.search).toHaveBeenCalled();
});
it('should call getFilters method', () => {
spyOn(component, 'getFilters');
component.getFilters();
expect(component.getFilters).toHaveBeenCalled();
it('should clear selection', () => {
spyOn(component, 'clearSelection');
component.clearSelection();
expect(component.clearSelection).toHaveBeenCalled();
});
it('should toggle view', () => {
component.toggleView('card');
expect(component.currentView).toBe('card');
component.toggleView('list');
expect(component.currentView).toBe('list');
});
it('should filter tree', () => {
const searchTerm = 'test';
spyOn(component, 'filterTree');
component.filterTree(searchTerm);
expect(component.filterTree).toHaveBeenCalledWith(searchTerm);
});
it('should add multiple clients', () => {
spyOn(component, 'addMultipleClients');
const event = new MouseEvent('click');
component.addMultipleClients(event);
expect(component.addMultipleClients).toHaveBeenCalledWith(event);
});
it('should expand path to node', () => {
const node: TreeNode = { id: '1', name: 'Node 1', type: 'type', children: [] };
spyOn(component, 'expandPathToNode');
component.expandPathToNode(node);
expect(component.expandPathToNode).toHaveBeenCalledWith(node);
});
});

View File

@ -3,7 +3,6 @@ import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatBottomSheet } from '@angular/material/bottom-sheet';
import { MatTabChangeEvent } from '@angular/material/tabs';
import { ToastrService } from 'ngx-toastr';
import { JoyrideService } from 'ngx-joyride';
import { FlatTreeControl } from '@angular/cdk/tree';
@ -16,7 +15,6 @@ import { CreateClientComponent } from './shared/clients/create-client/create-cli
import { EditOrganizationalUnitComponent } from './shared/organizational-units/edit-organizational-unit/edit-organizational-unit.component';
import { EditClientComponent } from './shared/clients/edit-client/edit-client.component';
import { ShowOrganizationalUnitComponent } from './shared/organizational-units/show-organizational-unit/show-organizational-unit.component';
import { TreeViewComponent } from './shared/tree-view/tree-view.component';
import { LegendComponent } from './shared/legend/legend.component';
import { DeleteModalComponent } from '../../shared/delete_modal/delete-modal/delete-modal.component';
import { ClassroomViewDialogComponent } from './shared/classroom-view/classroom-view-modal';
@ -24,6 +22,7 @@ import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import {CreateMultipleClientComponent} from "./shared/clients/create-multiple-client/create-multiple-client.component";
import {SelectionModel} from "@angular/cdk/collections";
enum NodeType {
OrganizationalUnit = 'organizational-unit',
@ -53,17 +52,17 @@ export class GroupsComponent implements OnInit, OnDestroy {
commands: Command[] = [];
commandsLoading = false;
selectedClients = new MatTableDataSource<Client>([]);
selection = new SelectionModel<any>(true, []);
cols = 4;
selectedClientsOriginal: Client[] = [];
currentView: 'card' | 'list' = 'list';
isTreeViewActive = false;
savedFilterNames: [string, string][] = [];
selectedTreeFilter = '';
syncStatus = false;
syncingClientId: string | null = null;
private originalTreeData: TreeNode[] = [];
arrayClients: any[] = [];
displayedColumns: string[] = ['status','sync', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
displayedColumns: string[] = ['select', 'status','sync', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
private _sort!: MatSort;
private _paginator!: MatPaginator;
@ -112,9 +111,8 @@ export class GroupsComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.search();
this.getFilters();
this.updateGridCols();
this.loadOrganizationalUnits();
this.refreshData();
window.addEventListener('resize', this.updateGridCols);
this.selectedClients.filterPredicate = (client: Client, filter: string): boolean => {
@ -144,40 +142,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
'@id': node['@id'],
});
private loadOrganizationalUnits(): void {
this.loading = true;
this.isLoadingClients = true;
this.dataService.getOrganizationalUnits().subscribe(
(data) => {
this.organizationalUnits = data;
this.loading = false;
if (this.organizationalUnits.length > 0) {
const treeData = this.organizationalUnits.map((unidad) => this.convertToTreeData(unidad));
this.treeDataSource.data = treeData.flat();
this.isTreeViewActive = true;
const firstNode = this.treeDataSource.data[0];
if (firstNode) {
this.selectedNode = firstNode;
this.fetchClientsForNode(firstNode);
}
} else {
this.toastr.info('No existen unidades organizativas');
this.isTreeViewActive = false;
this.isLoadingClients = false;
return;
}
},
(error) => {
console.error('Error fetching organizational units', error);
this.loading = false;
}
);
}
toggleView(view: 'card' | 'list'): void {
this.currentView = view;
}
@ -191,10 +155,22 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selectedUnidad = null;
this.selectedDetail = null;
this.selectedClients.data = [];
this.isTreeViewActive = false;
this.selectedNode = null;
}
// Función para obtener los filtros guardados actualmente deshabilitada
// getFilters(): void {
// this.subscriptions.add(
// this.dataService.getFilters().subscribe(
// (data) => {
// this.savedFilterNames = data.map((filter: { name: string; uuid: string; }) => [filter.name, filter.uuid]);
// },
// (error) => {
// console.error('Error fetching filters:', error);
// }
// )
// );
// }
getFilters(): void {
this.subscriptions.add(
this.dataService.getFilters().subscribe(
@ -208,21 +184,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
);
}
loadSelectedFilter(savedFilter: [string, string]): void {
this.subscriptions.add(
this.dataService.getFilter(savedFilter[1]).subscribe(
(response) => {
if (response) {
console.log('Filter:', response.filters);
}
},
(error) => {
console.error('Error:', error);
}
)
);
}
search(): void {
this.loading = true;
this.subscriptions.add(
@ -239,49 +200,112 @@ export class GroupsComponent implements OnInit, OnDestroy {
);
}
private async loadChildrenAndClients(id: string): Promise<UnidadOrganizativa> {
try {
const childrenData = await this.dataService.getChildren(id).toPromise();
const processHierarchy = (nodes: UnidadOrganizativa[]): UnidadOrganizativa[] => {
return nodes.map((node) => ({
...node,
children: node.children ? processHierarchy(node.children) : [],
}));
};
return {
...this.selectedUnidad!,
children: childrenData ? processHierarchy(childrenData) : [],
};
} catch (error) {
console.error('Error loading children:', error);
return this.selectedUnidad!;
}
}
private convertToTreeData(data: UnidadOrganizativa): TreeNode[] {
private convertToTreeData(data: UnidadOrganizativa): TreeNode {
const processNode = (node: UnidadOrganizativa): TreeNode => ({
id: node.id,
uuid: node.uuid,
name: node.name,
type: node.type,
'@id': node['@id'],
children: node.children?.map(processNode) || [],
hasClients: (node.clients?.length ?? 0) > 0,
});
return [processNode(data)];
return processNode(data);
}
private refreshData(selectedNodeIdOrUuid?: string): void {
this.loading = true;
this.isLoadingClients = !!selectedNodeIdOrUuid;
this.dataService.getOrganizationalUnits().subscribe({
next: (data) => {
this.originalTreeData = data.map((unidad) => this.convertToTreeData(unidad));
this.treeDataSource.data = [...this.originalTreeData];
if (selectedNodeIdOrUuid) {
this.selectedNode = this.findNodeByIdOrUuid(this.treeDataSource.data, selectedNodeIdOrUuid);
if (this.selectedNode) {
this.treeControl.collapseAll();
this.expandPathToNode(this.selectedNode);
this.fetchClientsForNode(this.selectedNode);
}
} else {
this.treeControl.collapseAll();
if (this.treeDataSource.data.length > 0) {
this.selectedNode = this.treeDataSource.data[0];
this.fetchClientsForNode(this.selectedNode);
} else {
this.selectedNode = null;
this.selectedClients.data = [];
}
}
this.loading = false;
this.isLoadingClients = false;
},
error: (error) => {
console.error('Error fetching organizational units', error);
this.toastr.error('Ocurrió un error al cargar las unidades organizativas');
this.loading = false;
this.isLoadingClients = false;
},
});
}
expandPathToNode(node: TreeNode): void {
const path: TreeNode[] = [];
let currentNode: TreeNode | null = node;
while (currentNode) {
path.unshift(currentNode);
currentNode = currentNode.id ? this.findParentNode(this.treeDataSource.data, currentNode.id) : null;
}
path.forEach((pathNode) => {
const flatNode = this.treeControl.dataNodes?.find((n) => n.id === pathNode.id);
if (flatNode) {
this.treeControl.expand(flatNode);
}
});
}
private findParentNode(treeData: TreeNode[], childId: string): TreeNode | null {
for (const node of treeData) {
if (node.children?.some((child) => child.id === childId)) {
return node;
}
if (node.children && node.children.length > 0) {
const parent = this.findParentNode(node.children, childId);
if (parent) {
return parent;
}
}
}
return null;
}
private findNodeByIdOrUuid(treeData: TreeNode[], identifier: string): TreeNode | null {
const search = (nodes: TreeNode[]): TreeNode | null => {
for (const node of nodes) {
if (node.id === identifier || node.uuid === identifier) return node;
if (node.children && node.children.length > 0) {
const found = search(node.children);
if (found) return found;
}
}
return null;
};
return search(treeData);
}
onNodeClick(node: TreeNode): void {
console.log('Node clicked:', node);
this.selectedNode = node;
this.fetchClientsForNode(node);
}
private fetchClientsForNode(node: TreeNode): void {
console.log('Node:', node);
this.isLoadingClients = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}`).subscribe({
next: (response) => {
@ -294,110 +318,82 @@ export class GroupsComponent implements OnInit, OnDestroy {
});
}
getNodeIcon(node: TreeNode): string {
switch (node.type) {
case NodeType.OrganizationalUnit:
return 'apartment';
case NodeType.ClassroomsGroup:
return 'doors';
case NodeType.Classroom:
return 'school';
case NodeType.ClientsGroup:
return 'lan';
case NodeType.Client:
return 'computer';
default:
return 'group';
}
}
addOU(event: MouseEvent, parent: TreeNode | null = null): void {
event.stopPropagation();
const dialogRef = this.dialog.open(CreateOrganizationalUnitComponent, {
data: { parent },
width: '900px',
});
dialogRef.afterClosed().subscribe(() => {
this.refreshOrganizationalUnits();
dialogRef.afterClosed().subscribe((newUnit) => {
if (newUnit?.uuid) {
console.log('Unidad organizativa creada:', newUnit);
this.refreshData(newUnit.uuid);
}
});
}
addClient(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(CreateClientComponent, {
data: { organizationalUnit },
data: { organizationalUnit: targetNode },
width: '900px',
});
dialogRef.afterClosed().subscribe(() => {
this.refreshOrganizationalUnits();
if (organizationalUnit && organizationalUnit['@id']) {
this.refreshClientsForNode(organizationalUnit);
dialogRef.afterClosed().subscribe((result) => {
if (result?.client && result?.organizationalUnit) {
const organizationalUnitUrl = result.organizationalUnit;
const uuid = organizationalUnitUrl.split('/')[2];
const parentNode = this.findNodeByIdOrUuid(this.treeDataSource.data, uuid);
if (parentNode) {
this.refreshData(parentNode.uuid);
}
}
});
}
addMultipleClients(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(CreateMultipleClientComponent, {
data: { organizationalUnit },
data: { organizationalUnit: targetNode },
width: '900px',
});
dialogRef.afterClosed().subscribe(() => {
this.refreshOrganizationalUnits();
if (organizationalUnit && organizationalUnit['@id']) {
this.refreshClientsForNode(organizationalUnit);
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
const organizationalUnitUrl = result.organizationalUnit;
const uuid = organizationalUnitUrl.split('/')[2];
const parentNode = this.findNodeByIdOrUuid(this.treeDataSource.data, uuid);
if (parentNode) {
console.log('Nodo padre encontrado para actualización:', parentNode);
this.refreshData(parentNode.uuid);
} else {
console.error('No se encontró el nodo padre después de la creación masiva.');
}
}
});
}
private refreshOrganizationalUnits(): void {
const expandedNodeIds = this.treeControl.dataNodes
? this.treeControl.dataNodes
.filter(node => this.treeControl.isExpanded(node))
.map(node => this.extractUuid(node['@id']))
: [];
this.subscriptions.add(
this.dataService.getOrganizationalUnits().subscribe(
(data) => {
this.organizationalUnits = data;
if (this.selectedUnidad) {
this.loadChildrenAndClients(this.selectedUnidad?.id || '').then((updatedData) => {
this.selectedUnidad = updatedData;
const treeData = this.convertToTreeData(updatedData);
this.originalTreeData = treeData[0]?.children || [];
this.treeDataSource.data = [...this.originalTreeData];
setTimeout(() => {
this.treeControl.dataNodes.forEach(node => {
const nodeId = this.extractUuid(node['@id']);
if (nodeId && expandedNodeIds.includes(nodeId)) {
this.treeControl.expand(node);
}
});
});
});
}
},
(error) => console.error('Error fetching organizational units', error)
)
);
}
onEditNode(event: MouseEvent, node: TreeNode | null): void {
event.stopPropagation();
const uuid = node ? this.extractUuid(node['@id']) : null;
if (!uuid) return;
if (node && node.type !== NodeType.Client) {
this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' });
} else {
this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
}
const dialogRef = node?.type !== NodeType.Client
? this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
: this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
dialogRef.afterClosed().subscribe(() => {
if (node) {
this.refreshData(node.id);
}
});
}
onDeleteClick(event: MouseEvent, node: TreeNode | null, clientNode?: TreeNode | null): void {
onDeleteClick(event: MouseEvent, node: TreeNode | null): void {
event.stopPropagation();
const uuid = node ? this.extractUuid(node['@id']) : null;
if (!uuid) return;
@ -410,44 +406,50 @@ export class GroupsComponent implements OnInit, OnDestroy {
dialogRef.afterClosed().subscribe((result) => {
if (result === true) {
this.deleteEntity(uuid, node.type, node);
this.deleteEntityorClient(uuid, node?.type);
}
});
}
private deleteEntity(uuid: string, type: string, node: TreeNode): void {
this.subscriptions.add(
this.dataService.deleteElement(uuid, type).subscribe(
() => {
this.refreshOrganizationalUnits();
if (type === NodeType.Client) {
this.refreshClientsForNode(node);
}
this.toastr.success('Entidad eliminada exitosamente');
},
(error) => {
console.error('Error deleting entity:', error);
this.toastr.error('Error al eliminar la entidad', error.message);
}
)
);
}
private deleteEntityorClient(uuid: string, type: string): void {
if (!this.selectedNode) return;
private refreshClientsForNode(node: TreeNode): void {
if (!node['@id']) {
this.selectedClients.data = [];
return;
}
this.fetchClientsForNode(node);
const parentNode = this.selectedNode?.id
? this.findParentNode(this.treeDataSource.data, this.selectedNode.id)
: null;
this.dataService.deleteElement(uuid, type).subscribe({
next: () => {
const entityType = type === NodeType.Client ? 'Cliente' : 'Entidad';
const verb = type === NodeType.Client ? 'eliminado' : 'eliminada';
this.toastr.success(`${entityType} ${verb} exitosamente`);
if (type === NodeType.Client) {
this.refreshData(this.selectedNode?.id);
} else if (parentNode) {
this.refreshData(parentNode.id);
} else {
this.refreshData();
}
},
error: (error) => {
console.error('Error deleting entity:', error);
const entityType = type === NodeType.Client ? 'cliente' : 'entidad';
this.toastr.error(`Error al eliminar el ${entityType}`);
},
});
}
onEditClick(event: MouseEvent, type: string, uuid: string): void {
event.stopPropagation();
if (type !== NodeType.Client) {
this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' });
} else {
this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
}
const dialogRef = type !== NodeType.Client
? this.dialog.open(EditOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
: this.dialog.open(EditClientComponent, { data: { uuid }, width: '900px' });
dialogRef.afterClosed().subscribe(() => {
this.refreshData(this.selectedNode?.id);
});
}
onRoomMap(room: TreeNode | null): void {
@ -467,22 +469,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
);
}
fetchCommands(): void {
this.commandsLoading = true;
this.subscriptions.add(
this.http.get<{ 'hydra:member': Command[] }>(`${this.baseUrl}/commands?page=1&itemsPerPage=30`).subscribe(
(response) => {
this.commands = response['hydra:member'];
this.commandsLoading = false;
},
(error) => {
console.error('Error fetching commands:', error);
this.commandsLoading = false;
}
)
);
}
executeCommand(command: Command, selectedNode: TreeNode | null): void {
if (!selectedNode) {
@ -509,13 +495,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
}
onTreeClick(event: MouseEvent, data: TreeNode): void {
event.stopPropagation();
if (data.type !== NodeType.Client) {
this.dialog.open(TreeViewComponent, { data: { data }, width: '800px' });
}
}
openBottomSheet(): void {
this.bottomSheet.open(LegendComponent);
}
@ -531,31 +510,54 @@ export class GroupsComponent implements OnInit, OnDestroy {
hasChild = (_: number, node: FlatNode): boolean => node.expandable;
isLeafNode = (_: number, node: FlatNode): boolean => !node.expandable;
filterTree(searchTerm: string, filterType: string): void {
const filterNodes = (nodes: TreeNode[]): TreeNode[] => {
const filteredNodes: TreeNode[] = [];
for (const node of nodes) {
const matchesName = node.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType ? node.type.toLowerCase() === filterType.toLowerCase() : true;
const filteredChildren = node.children ? filterNodes(node.children) : [];
filterTree(searchTerm: string): void {
const expandPaths: TreeNode[][] = [];
if ((matchesName && matchesType) || filteredChildren.length > 0) {
filteredNodes.push({ ...node, children: filteredChildren });
}
}
return filteredNodes;
const filterNodes = (nodes: TreeNode[], parentPath: TreeNode[] = []): TreeNode[] => {
return nodes
.map((node) => {
const matchesName = node.name.toLowerCase().includes(searchTerm.toLowerCase());
const filteredChildren = node.children ? filterNodes(node.children, [...parentPath, node]) : [];
if (matchesName) {
expandPaths.push([...parentPath, node]);
return {
...node,
children: node.children,
} as TreeNode;
} else if (filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
} as TreeNode;
}
return null;
})
.filter((node): node is TreeNode => node !== null);
};
const filteredData = filterNodes(this.originalTreeData);
this.treeDataSource.data = filteredData;
if (!searchTerm) {
this.treeDataSource.data = [...this.originalTreeData];
this.treeControl.collapseAll();
} else {
this.treeDataSource.data = filterNodes(this.originalTreeData);
expandPaths.forEach((path) => this.expandPath(path));
}
}
private expandPath(path: TreeNode[]): void {
path.forEach((pathNode) => {
const flatNode = this.treeControl.dataNodes?.find((n) => n.id === pathNode.id);
if (flatNode) {
this.treeControl.expand(flatNode);
}
});
}
onTreeFilterInput(event: Event): void {
const input = event.target as HTMLInputElement;
const searchTerm = input?.value || '';
this.filterTree(searchTerm, this.selectedTreeFilter);
const searchTerm = input?.value.trim() || '';
this.filterTree(searchTerm);
}
onClientFilterInput(event: Event): void {
@ -569,7 +571,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selectedClients.filter = this.searchTerm;
}
public setSelectedNode(node: TreeNode): void {
this.selectedNode = node;
}
@ -586,19 +587,68 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.toastr.success('Cliente actualizado correctamente');
this.syncStatus = false;
this.syncingClientId = null;
this.search()
this.refreshData()
},
() => {
this.toastr.error('Error de conexión con el cliente');
this.syncStatus = false;
this.syncingClientId = null;
this.search()
this.refreshData()
}
)
);
}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.selectedClients.data.length;
return numSelected === numRows;
}
toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
this.arrayClients = []
return;
}
this.selection.select(...this.selectedClients.data);
this.arrayClients = [...this.selection.selected];
}
toggleRow(row: any) {
this.selection.toggle(row);
this.updateSelectedClients();
}
updateSelectedClients() {
this.arrayClients = [...this.selection.selected];
}
getClientPath(client: Client): string {
const path: string[] = [];
let currentNode: TreeNode | null = this.findNodeByIdOrUuid(this.treeDataSource.data, client.organizationalUnit.uuid);
while (currentNode) {
path.unshift(currentNode.name);
currentNode = currentNode.id ? this.findParentNode(this.treeDataSource.data, currentNode.id) : null;
}
return path.join(' / ');
}
private extractUuid(idPath: string | undefined): string | null {
return idPath ? idPath.split('/').pop() || null : null;
}
clearTreeSearch(inputElement: HTMLInputElement): void {
inputElement.value = '';
this.filterTree('');
}
clearClientSearch(inputElement: HTMLInputElement): void {
inputElement.value = '';
this.filterClients('');
}
}

View File

@ -61,6 +61,7 @@ export interface ClientCollection {
export interface TreeNode {
id?: string
uuid?: string;
name: string;
type: string;
'@id'?: string;

View File

@ -231,5 +231,15 @@ export class DataService {
);
}
getOrganizationalUnitPath(unit: UnidadOrganizativa, units: UnidadOrganizativa[]): string {
const path: string[] = [];
let currentUnit: UnidadOrganizativa | undefined = unit;
while (currentUnit) {
path.unshift(currentUnit.name);
currentUnit = units.find(u => u['@id'] === currentUnit?.parent?.['@id']);
}
return path.join(' / ');
}
}

View File

@ -6,9 +6,12 @@
<mat-form-field class="form-field">
<mat-label i18n="@@organizational-unit-label">Padre</mat-label>
<mat-select formControlName="organizationalUnit">
<mat-option *ngFor="let unit of parentUnits" [value]="unit['@id']">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div class="unit-name">{{ unit.name }}</div>
<div class="unit-path">{{ unit.path }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -15,6 +15,7 @@ export class CreateClientComponent implements OnInit {
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
clientForm!: FormGroup;
parentUnits: any[] = [];
parentUnitsWithPaths: { id: string, name: string, path: string }[] = [];
hardwareProfiles: any[] = [];
ogLives: any[] = [];
menus: any[] = [];
@ -80,6 +81,11 @@ export class CreateClientComponent implements OnInit {
this.http.get<any>(url).subscribe(
response => {
this.parentUnits = response['hydra:member'];
this.parentUnitsWithPaths = this.parentUnits.map(unit => ({
id: unit['@id'],
name: unit.name,
path: this.dataService.getOrganizationalUnitPath(unit, this.parentUnits)
}));
this.loading = false;
},
error => {
@ -89,6 +95,11 @@ export class CreateClientComponent implements OnInit {
);
}
getSelectedParentName(): string | undefined {
const parentId = this.clientForm.get('organizationalUnit')?.value;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
loadHardwareProfiles(): void {
this.dataService.getHardwareProfiles().subscribe(
(data: any[]) => {
@ -157,15 +168,21 @@ export class CreateClientComponent implements OnInit {
onSubmit(): void {
if (this.clientForm.valid) {
const formData = this.clientForm.value;
this.http.post(`${this.baseUrl}/clients`, formData).subscribe(
response => {
(response) => {
this.toastService.success('Cliente creado exitosamente', 'Éxito');
this.dialogRef.close(response);
this.dialogRef.close({
client: response,
organizationalUnit: formData.organizationalUnit,
});
},
error => {
this.toastService.error('Error al crear el cliente', 'Error');
(error) => {
this.toastService.error(error.error['hydra:description'], 'Error al crear el cliente');
}
);
} else {
this.toastService.error('Formulario inválido. Por favor, revise los campos obligatorios.', 'Error');
}
}

View File

@ -6,9 +6,13 @@
<form class="client-form grid-form" *ngIf="!loading">
<mat-form-field class="form-field">
<mat-label>{{ 'organizationalUnitLabel' | translate }}</mat-label>
<mat-select (selectionChange)="setOrganizationalUnit($event)">
<mat-option *ngFor="let unit of parentUnits" [value]="unit['@id']">
<mat-select (selectionChange)="setOrganizationalUnit($event)" [value]="organizationalUnit">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div class="unit-name">{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -3,6 +3,8 @@ import {MatDialogRef} from "@angular/material/dialog";
import {HttpClient} from "@angular/common/http";
import {MatSnackBar} from "@angular/material/snack-bar";
import {ToastrService} from "ngx-toastr";
import {MAT_DIALOG_DATA} from "@angular/material/dialog";
import { DataService } from '../../../services/data.service';
@Component({
selector: 'app-create-multiple-client',
@ -12,21 +14,29 @@ import {ToastrService} from "ngx-toastr";
export class CreateMultipleClientComponent implements OnInit{
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
parentUnits: any[] = [];
parentUnitsWithPaths: { id: string, name: string, path: string }[] = [];
uploadedClients: any[] = [];
loading: boolean = false;
displayedColumns: string[] = ['name', 'ip', 'mac'];
showTextarea: boolean = true;
organizationalUnit: any;
regex: RegExp = /host\s+(\S+)\s+\{\s+hardware\s+ethernet\s+([a-zA-Z0-9]{2}(:[a-zA-Z0-9]{2}){5});\s+fixed-address\s+((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?));\s+\}/g;
constructor(
@Optional() private dialogRef: MatDialogRef<CreateMultipleClientComponent>,
@Inject(MAT_DIALOG_DATA) private data: any,
private http: HttpClient,
private snackBar: MatSnackBar,
private toastService: ToastrService
private toastService: ToastrService,
private dataService: DataService
) {}
ngOnInit(): void {
this.loadParentUnits();
if (this.data?.organizationalUnit) {
this.organizationalUnit = this.data.organizationalUnit['@id'];
}
}
loadParentUnits(): void {
@ -36,6 +46,11 @@ export class CreateMultipleClientComponent implements OnInit{
this.http.get<any>(url).subscribe(
response => {
this.parentUnits = response['hydra:member'];
this.parentUnitsWithPaths = this.parentUnits.map(unit => ({
id: unit['@id'],
name: unit.name,
path: this.dataService.getOrganizationalUnitPath(unit, this.parentUnits)
}));
this.loading = false;
},
error => {
@ -45,8 +60,12 @@ export class CreateMultipleClientComponent implements OnInit{
);
}
getSelectedParentName(): string | undefined {
const parentId = this.organizationalUnit;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
setOrganizationalUnit(organizationalUnit: any): void {
console.log('Organizational unit selected:', organizationalUnit.value);
this.organizationalUnit = organizationalUnit.value;
}
@ -57,15 +76,14 @@ export class CreateMultipleClientComponent implements OnInit{
reader.onload = (e: any) => {
const textData = e.target.result;
const regex = /host\s+(\S+)\s+\{\s+hardware\s+ethernet\s+([\da-fA-F:]+);\s+fixed-address\s+([\d.]+);\s+\}/g;
let match;
const clients = [];
while ((match = regex.exec(textData)) !== null) {
while ((match = this.regex.exec(textData)) !== null) {
clients.push({
name: match[1],
mac: match[2],
ip: match[3]
ip: match[4]
});
}
@ -84,15 +102,14 @@ export class CreateMultipleClientComponent implements OnInit{
}
onTextarea(text: string): void {
const regex = /host\s+(\S+)\s+\{\s+hardware\s+ethernet\s+([\da-fA-F:]+);\s+fixed-address\s+([\d.]+);\s+\}/g;
let match;
const clients = [];
while ((match = regex.exec(text)) !== null) {
while ((match = this.regex.exec(text)) !== null) {
clients.push({
name: match[1],
mac: match[2],
ip: match[3]
ip: match[4]
});
}
@ -108,6 +125,9 @@ export class CreateMultipleClientComponent implements OnInit{
onSubmit(): void {
if (this.uploadedClients.length > 0) {
let successCount = 0;
let errorMessages: string[] = [];
this.uploadedClients.forEach(client => {
const formData = {
organizationalUnit: this.organizationalUnit,
@ -118,20 +138,37 @@ export class CreateMultipleClientComponent implements OnInit{
this.http.post(`${this.baseUrl}/clients`, formData).subscribe(
response => {
this.toastService.success(`Cliente ${client.name} creado exitosamente`, 'Éxito');
successCount++;
if (successCount + errorMessages.length === this.uploadedClients.length) {
this.showFinalToast(successCount, errorMessages);
}
},
error => {
this.toastService.error(error.error['hydra:description'], `Error al crear el cliente ${client.name}`);
errorMessages.push(`Error al crear el cliente ${client.name}: ${error.error['hydra:description']}`);
if (successCount + errorMessages.length === this.uploadedClients.length) {
this.showFinalToast(successCount, errorMessages);
}
}
);
});
this.uploadedClients = [];
this.dialogRef.close();
} else {
this.toastService.error('No hay clientes cargados para añadir', 'Error');
}
}
showFinalToast(successCount: number, errorMessages: string[]): void {
if (successCount > 0) {
this.toastService.success(`${successCount} clientes creados exitosamente`, 'Éxito');
}
if (errorMessages.length > 0) {
errorMessages.forEach(message => this.toastService.error(message, 'Error'));
}
this.dialogRef.close({
success: successCount > 0,
organizationalUnit: this.organizationalUnit,
});
}
onNoClick(): void {
this.dialogRef.close();
}

View File

@ -6,8 +6,12 @@
<mat-form-field class="form-field">
<mat-label>{{ 'organizationalUnitLabel' | translate }}</mat-label>
<mat-select formControlName="organizationalUnit">
<mat-option *ngFor="let unit of parentUnits" [value]="unit['@id']">
{{ unit.name }}
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div class="unit-name">{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -15,6 +15,7 @@ export class EditClientComponent {
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
clientForm!: FormGroup;
parentUnits: any[] = [];
parentUnitsWithPaths: { id: string, name: string, path: string }[] = [];
hardwareProfiles: any[] = [];
repositories: any[] = [];
ogLives: any[] = [];
@ -68,19 +69,32 @@ export class EditClientComponent {
});
}
loadParentUnits() {
loadParentUnits(): void {
this.loading = true;
const url = `${this.baseUrl}/organizational-units?page=1&itemsPerPage=10000`;
this.http.get<any>(url).subscribe(
response => {
this.parentUnits = response['hydra:member'];
this.parentUnitsWithPaths = this.parentUnits.map(unit => ({
id: unit['@id'],
name: unit.name,
path: this.dataService.getOrganizationalUnitPath(unit, this.parentUnits)
}));
this.loading = false;
},
error => {
console.error('Error fetching parent units:', error);
this.loading = false;
}
);
}
getSelectedParentName(): string | undefined {
const parentId = this.clientForm.get('organizationalUnit')?.value;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
loadHardwareProfiles(): void {
this.dataService.getHardwareProfiles().subscribe(
(data: any[]) => {

View File

@ -1,182 +1,160 @@
<h1 mat-dialog-title>{{ 'addOrgUnitTitle' | translate }}</h1>
<div mat-dialog-content>
<mat-stepper orientation="vertical" [linear]="isLinear">
<!-- Paso 1: General -->
<mat-step [stepControl]="generalFormGroup">
<form [formGroup]="generalFormGroup">
<ng-template matStepLabel>{{ 'generalStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'typeLabel' | translate }}</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of filteredTypes" [value]="type">
{{ typeTranslations[type] }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'nameLabel' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'createOrgUnitparentLabel' | translate }}</mat-label>
<mat-select formControlName="parent">
<mat-option>{{ 'noParentOption' | translate }}</mat-option>
<mat-option *ngFor="let unit of parentUnits" [value]="unit['@id']">{{ unit.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'descriptionLabel' | translate }}</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
<div>
<button mat-button matStepperNext>{{ 'nextButton' | translate }}</button>
</div>
</form>
</mat-step>
<!-- Paso 1: General -->
<form [formGroup]="generalFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'typeLabel' | translate }}</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of filteredTypes" [value]="type">
{{ typeTranslations[type] }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'nameLabel' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'createOrgUnitparentLabel' | translate }}</mat-label>
<mat-select formControlName="parent">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div>{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'descriptionLabel' | translate }}</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
</form>
<!-- Paso 2: Información del Aula -->
<mat-step *ngIf="generalFormGroup.value.type === 'classroom'" [stepControl]="classroomInfoFormGroup">
<form [formGroup]="classroomInfoFormGroup">
<ng-template matStepLabel>{{ 'classroomInfoStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'locationLabel' | translate }}</mat-label>
<input matInput formControlName="location">
</mat-form-field>
<mat-slide-toggle formControlName="projector">{{ 'projectorToggle' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="board">{{ 'boardToggle' | translate }}</mat-slide-toggle>
<mat-form-field class="form-field">
<mat-label>{{ 'capacityLabel' | translate }}</mat-label>
<input matInput formControlName="capacity" type="number">
</mat-form-field>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'associatedCalendarLabel' | translate }}</mat-label>
<mat-select formControlName="remoteCalendar" (selectionChange)="onCalendarChange($event)">
<mat-option *ngFor="let calendar of calendars" [value]="calendar['@id']">
{{ calendar.name }}
</mat-option>
</mat-select>
</mat-form-field>
<!-- Paso 2: Información del Aula -->
<form *ngIf="generalFormGroup.value.type === 'classroom'" [formGroup]="classroomInfoFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'locationLabel' | translate }}</mat-label>
<input matInput formControlName="location">
</mat-form-field>
<mat-slide-toggle formControlName="projector">{{ 'projectorToggle' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="board">{{ 'boardToggle' | translate }}</mat-slide-toggle>
<mat-form-field class="form-field">
<mat-label>{{ 'capacityLabel' | translate }}</mat-label>
<input matInput formControlName="capacity" type="number">
</mat-form-field>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'associatedCalendarLabel' | translate }}</mat-label>
<mat-select formControlName="remoteCalendar" (selectionChange)="onCalendarChange($event)">
<mat-option *ngFor="let calendar of calendars" [value]="calendar['@id']">
{{ calendar.name }}
</mat-option>
</mat-select>
</mat-form-field>
</form>
<div>
<button mat-button matStepperPrevious>{{ 'backButton' | translate }}</button>
<button mat-button matStepperNext>{{ 'nextButton' | translate }}</button>
</div>
</form>
</mat-step>
<!-- Paso 3: Información Adicional -->
<form [formGroup]="additionalInfoFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'commentsLabel' | translate }}</mat-label>
<textarea matInput formControlName="comments"></textarea>
</mat-form-field>
</form>
<!-- Paso 3: Información Adicional -->
<mat-step [stepControl]="additionalInfoFormGroup">
<form [formGroup]="additionalInfoFormGroup">
<ng-template matStepLabel>{{ 'additionalInfoStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'commentsLabel' | translate }}</mat-label>
<textarea matInput formControlName="comments"></textarea>
</mat-form-field>
<div>
<button mat-button matStepperPrevious>{{ 'backButton' | translate }}</button>
<button mat-button matStepperNext>{{ 'nextButton' | translate }}</button>
</div>
</form>
</mat-step>
<!-- Paso 4: Configuración de Red -->
<form *ngIf="generalFormGroup.value.type === 'classroom' || generalFormGroup.value.type === 'clients-group'" [formGroup]="networkSettingsFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'ogLiveLabel' | translate }}</mat-label>
<mat-select formControlName="oglive" (selectionChange)="onOgLiveChange($event)">
<mat-option *ngFor="let oglive of ogLives" [value]="oglive['@id']">
{{ oglive.filename }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'repositoryLabel' | translate }}</mat-label>
<mat-select formControlName="repository" (selectionChange)="onRepositoryChange($event)">
<mat-option *ngFor="let repository of repositories" [value]="repository['@id']">
{{ repository.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'nextServerLabel' | translate }}</mat-label>
<input matInput formControlName="nextServer">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'bootFileNameLabel' | translate }}</mat-label>
<input matInput formControlName="bootFileName">
</mat-form-field>
<!-- Paso 4: Configuración de Red -->
<mat-step *ngIf="generalFormGroup.value.type === 'classroom'" [stepControl]="networkSettingsFormGroup">
<form [formGroup]="networkSettingsFormGroup">
<ng-template matStepLabel>{{ 'networkSettingsStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'ogLiveLabel' | translate }}</mat-label>
<mat-select formControlName="oglive" (selectionChange)="onOgLiveChange($event)">
<mat-option *ngFor="let oglive of ogLives" [value]="oglive['@id']">
{{ oglive.filename }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'repositoryLabel' | translate }}</mat-label>
<mat-select formControlName="repository" (selectionChange)="onRepositoryChange($event)">
<mat-option *ngFor="let repository of repositories" [value]="repository['@id']">
{{ repository.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'nextServerLabel' | translate }}</mat-label>
<input matInput formControlName="nextServer">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'bootFileNameLabel' | translate }}</mat-label>
<input matInput formControlName="bootFileName">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'proxyUrlLabel' | translate }}</mat-label>
<input matInput formControlName="proxy">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'dnsIpLabel' | translate }}</mat-label>
<input matInput formControlName="dns">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'netmaskLabel' | translate }}</mat-label>
<input matInput formControlName="netmask">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'routerLabel' | translate }}</mat-label>
<input matInput formControlName="router">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'ntpIpLabel' | translate }}</mat-label>
<input matInput formControlName="ntp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pModeLabel' | translate }}</mat-label>
<mat-select formControlName="p2pMode">
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value">
{{ option.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pTimeLabel' | translate }}</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastIpLabel' | translate }}</mat-label>
<input matInput formControlName="mcastIp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastSpeedLabel' | translate }}</mat-label>
<input matInput formControlName="mcastSpeed" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastPortLabel' | translate }}</mat-label>
<input matInput formControlName="mcastPort" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastModeLabel' | translate }}</mat-label>
<mat-select formControlName="mcastMode">
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value">
{{ option.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'menuUrlLabel' | translate }}</mat-label>
<input matInput formControlName="menu" type="url">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'hardwareProfileLabel' | translate }}</mat-label>
<mat-select formControlName="hardwareProfile">
<mat-option *ngFor="let unit of hardwareProfiles" [value]="unit['@id']">{{ unit.description }}</mat-option>
</mat-select>
<mat-error>{{ 'urlFormatError' | translate }}</mat-error>
</mat-form-field>
</form>
</mat-step>
</mat-stepper>
<mat-form-field class="form-field">
<mat-label>{{ 'proxyUrlLabel' | translate }}</mat-label>
<input matInput formControlName="proxy">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'dnsIpLabel' | translate }}</mat-label>
<input matInput formControlName="dns">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'netmaskLabel' | translate }}</mat-label>
<input matInput formControlName="netmask">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'routerLabel' | translate }}</mat-label>
<input matInput formControlName="router">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'ntpIpLabel' | translate }}</mat-label>
<input matInput formControlName="ntp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pModeLabel' | translate }}</mat-label>
<mat-select formControlName="p2pMode">
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value">
{{ option.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pTimeLabel' | translate }}</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastIpLabel' | translate }}</mat-label>
<input matInput formControlName="mcastIp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastSpeedLabel' | translate }}</mat-label>
<input matInput formControlName="mcastSpeed" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastPortLabel' | translate }}</mat-label>
<input matInput formControlName="mcastPort" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastModeLabel' | translate }}</mat-label>
<mat-select formControlName="mcastMode">
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value">
{{ option.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'menuUrlLabel' | translate }}</mat-label>
<input matInput formControlName="menu" type="url">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'hardwareProfileLabel' | translate }}</mat-label>
<mat-select formControlName="hardwareProfile">
<mat-option *ngFor="let unit of hardwareProfiles" [value]="unit['@id']">{{ unit.description }}</mat-option>
</mat-select>
<mat-error>{{ 'urlFormatError' | translate }}</mat-error>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onNoClick()">{{ 'cancelButton' | translate }}</button>
<button mat-button (click)="onSubmit()" [disabled]="!networkSettingsFormGroup.valid">{{ 'submitButton' | translate }}</button>
<button mat-button (click)="onSubmit()" [disabled]="!networkSettingsFormGroup.valid">{{ 'addOUSubmitButton' | translate }}</button>
</div>

View File

@ -25,9 +25,9 @@ export class CreateOrganizationalUnitComponent implements OnInit {
'clients-group': 'Grupo de clientes'
};
protected p2pModeOptions = [
{ name: 'Leecher', value: 'p2p-mode-leecher' },
{ name: 'Peer', value: 'p2p-mode-peer' },
{ name: 'Seeder', value: 'p2p-mode-seeder' },
{ name: 'Leecher', value: 'leecher' },
{ name: 'Peer', value: 'peer' },
{ name: 'Seeder', value: 'seeder' },
];
protected multicastModeOptions = [
{"name": 'Half duplex', "value": "half"},
@ -39,9 +39,9 @@ export class CreateOrganizationalUnitComponent implements OnInit {
ogLives: any[] = [];
repositories: any[] = [];
selectedCalendarUuid: string | null = null;
parentUnitsWithPaths: { id: string, name: string, path: string }[] = [];
@Output() unitAdded = new EventEmitter();
@Output() unitAdded = new EventEmitter<{ uuid: string; name: string }>();
constructor(
private _formBuilder: FormBuilder,
@ -104,11 +104,23 @@ export class CreateOrganizationalUnitComponent implements OnInit {
loadParentUnits() {
const url = `${this.baseUrl}/organizational-units?page=1&itemsPerPage=1000`;
this.http.get<any>(url).subscribe(
response => this.parentUnits = response['hydra:member'],
response => {
this.parentUnits = response['hydra:member'];
this.parentUnitsWithPaths = this.parentUnits.map(unit => ({
id: unit['@id'],
name: unit.name,
path: this.dataService.getOrganizationalUnitPath(unit, this.parentUnits)
}));
},
error => console.error('Error fetching parent units:', error)
);
}
getSelectedParentName(): string | undefined {
const parentId = this.generalFormGroup.get('parent')?.value;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
loadHardwareProfiles(): void {
this.dataService.getHardwareProfiles().subscribe(
(data: any[]) => this.hardwareProfiles = data,
@ -118,7 +130,9 @@ export class CreateOrganizationalUnitComponent implements OnInit {
loadOgLives() {
this.dataService.getOgLives().subscribe(
(data: any[]) => this.ogLives = data,
(data: any[]) => {
this.ogLives = data
},
error => console.error('Error fetching ogLives', error)
);
}
@ -160,10 +174,10 @@ export class CreateOrganizationalUnitComponent implements OnInit {
this.http.post<any>(postUrl, formData, { headers }).subscribe(
response => {
this.unitAdded.emit();
this.dialogRef.close();
this.toastService.success('Cliente creado exitosamente', 'Éxito');
this.openSnackBar(false, 'Cliente creado exitosamente');
this.unitAdded.emit(response);
this.dialogRef.close(response);
this.toastService.success('Unidad creada exitosamente', 'Éxito');
this.openSnackBar(false, 'Unidad creada exitosamente');
},
error => {
console.error('Error al realizar POST:', error);
@ -218,9 +232,9 @@ export class CreateOrganizationalUnitComponent implements OnInit {
openSnackBar(isError: boolean, message: string) {
if (isError) {
this.toastService.error('Error al crear el cliente: ' + message, 'Error');
this.toastService.error('Error al crear la unidad: ' + message, 'Error');
} else {
this.toastService.success('Cliente creado exitosamente', 'Éxito');
this.toastService.success('Unidad creada exitosamente', 'Éxito');
}
}
}

View File

@ -5,35 +5,34 @@ h1 {
color: #3f51b5;
margin-bottom: 20px;
}
.network-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-field {
width: 100%;
margin-top: 10px;
}
.mat-dialog-content {
padding: 20px;
}
.mat-dialog-actions {
display: flex;
justify-content: flex-end;
padding: 10px 20px;
}
button {
text-transform: none;
font-size: 16px;
font-weight: 500;
}
.mat-slide-toggle {
margin-top: 20px;
}

View File

@ -1,170 +1,148 @@
<h1 mat-dialog-title>{{ 'editOrgUnitTitle' | translate }}</h1>
<div mat-dialog-content>
<mat-stepper orientation="vertical" [linear]="isLinear">
<!-- Paso 1: General -->
<mat-step [stepControl]="generalFormGroup">
<form [formGroup]="generalFormGroup">
<ng-template matStepLabel>{{ 'generalStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'typeLabel' | translate }}</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of filteredTypes" [value]="type">{{ type }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'nameLabel' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'editOrgUnitParentLabel' | translate }}</mat-label>
<mat-select formControlName="parent">
<mat-option *ngFor="let unit of parentUnits" [value]="unit['@id']">{{ unit.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'descriptionLabel' | translate }}</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
<div>
<button mat-button matStepperNext>{{ 'nextButton' | translate }}</button>
</div>
</form>
</mat-step>
<!-- Paso 1: General -->
<form [formGroup]="generalFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'typeLabel' | translate }}</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of filteredTypes" [value]="type">
{{ typeTranslations[type] }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'nameLabel' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'editOrgUnitParentLabel' | translate }}</mat-label>
<mat-select formControlName="parent">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div>{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'descriptionLabel' | translate }}</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
</form>
<!-- Paso 2: Información del Aula -->
<mat-step *ngIf="generalFormGroup.value.type === 'classroom'" [stepControl]="classroomInfoFormGroup">
<form [formGroup]="classroomInfoFormGroup">
<ng-template matStepLabel>{{ 'classroomInfoStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'locationLabel' | translate }}</mat-label>
<input matInput formControlName="location">
</mat-form-field>
<mat-slide-toggle formControlName="projector">{{ 'projectorToggle' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="board">{{ 'boardToggle' | translate }}</mat-slide-toggle>
<mat-form-field class="form-field">
<mat-label>{{ 'capacityLabel' | translate }}</mat-label>
<input matInput formControlName="capacity" type="number">
</mat-form-field>
<!-- Paso 2: Información del Aula -->
<form *ngIf="generalFormGroup.value.type === 'classroom'" [formGroup]="classroomInfoFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'locationLabel' | translate }}</mat-label>
<input matInput formControlName="location">
</mat-form-field>
<mat-slide-toggle formControlName="projector">{{ 'projectorToggle' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="board">{{ 'boardToggle' | translate }}</mat-slide-toggle>
<mat-form-field class="form-field">
<mat-label>{{ 'capacityLabel' | translate }}</mat-label>
<input matInput formControlName="capacity" type="number">
</mat-form-field>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'associatedCalendarLabel' | translate }}</mat-label>
<mat-select formControlName="remoteCalendar" (selectionChange)="onCalendarChange($event)">
<mat-option *ngFor="let calendar of calendars" [value]="calendar['@id']">
{{ calendar.name }}
</mat-option>
</mat-select>
</mat-form-field>
</form>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'associatedCalendarLabel' | translate }}</mat-label>
<mat-select formControlName="remoteCalendar" (selectionChange)="onCalendarChange($event)">
<mat-option *ngFor="let calendar of calendars" [value]="calendar['@id']">
{{ calendar.name }}
</mat-option>
</mat-select>
</mat-form-field>
<!-- Paso 3: Información Adicional -->
<form [formGroup]="additionalInfoFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'commentsLabel' | translate }}</mat-label>
<textarea matInput formControlName="comments"></textarea>
</mat-form-field>
</form>
<div>
<button mat-button matStepperPrevious>{{ 'backButton' | translate }}</button>
<button mat-button matStepperNext>{{ 'nextButton' | translate }}</button>
</div>
</form>
</mat-step>
<!-- Paso 3: Información Adicional -->
<mat-step [stepControl]="additionalInfoFormGroup">
<form [formGroup]="additionalInfoFormGroup">
<ng-template matStepLabel>{{ 'additionalInfoStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'commentsLabel' | translate }}</mat-label>
<textarea matInput formControlName="comments"></textarea>
</mat-form-field>
<div>
<button mat-button matStepperPrevious>{{ 'backButton' | translate }}</button>
<button mat-button matStepperNext>{{ 'nextButton' | translate }}</button>
</div>
</form>
</mat-step>
<!-- Paso 4: Configuración de Red -->
<mat-step [stepControl]="networkSettingsFormGroup">
<form [formGroup]="networkSettingsFormGroup">
<ng-template matStepLabel>{{ 'networkSettingsStepLabel' | translate }}</ng-template>
<mat-form-field class="form-field">
<mat-label>{{ 'ogLiveLabel' | translate }}</mat-label>
<mat-select formControlName="ogLive" (selectionChange)="onOgLiveChange($event)">
<mat-option *ngFor="let oglive of ogLives" [value]="oglive['@id']">
{{ oglive.filename }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'repositoryLabel' | translate }}</mat-label>
<mat-select formControlName="repository" (selectionChange)="onRepositoryChange($event)">
<mat-option *ngFor="let repository of repositories" [value]="repository['@id']">
{{ repository.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'proxyUrlLabel' | translate }}</mat-label>
<input matInput formControlName="proxy">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'dnsIpLabel' | translate }}</mat-label>
<input matInput formControlName="dns">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'netmaskLabel' | translate }}</mat-label>
<input matInput formControlName="netmask">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'routerLabel' | translate }}</mat-label>
<input matInput formControlName="router">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'ntpIpLabel' | translate }}</mat-label>
<input matInput formControlName="ntp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pModeLabel' | translate }}</mat-label>
<mat-select formControlName="p2pMode">
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value">{{ option.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pTimeLabel' | translate }}</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastIpLabel' | translate }}</mat-label>
<input matInput formControlName="mcastIp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastSpeedLabel' | translate }}</mat-label>
<input matInput formControlName="mcastSpeed" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastPortLabel' | translate }}</mat-label>
<input matInput formControlName="mcastPort" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastModeLabel' | translate }}</mat-label>
<mat-select formControlName="mcastMode">
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value">{{ option.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'menuUrlLabel' | translate }}</mat-label>
<input matInput formControlName="menu" type="url">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'hardwareProfileLabel' | translate }}</mat-label>
<mat-select formControlName="hardwareProfile">
<mat-option *ngFor="let unit of hardwareProfiles" [value]="unit['@id']">{{ unit.description }}</mat-option>
</mat-select>
<mat-error>{{ 'urlFormatError' | translate }}</mat-error>
</mat-form-field>
<mat-slide-toggle formControlName="validation">{{ 'validationToggle' | translate }}</mat-slide-toggle>
<div>
<button mat-button matStepperPrevious>{{ 'backButton' | translate }}</button>
</div>
</form>
</mat-step>
</mat-stepper>
<!-- Paso 4: Configuración de Red -->
<form *ngIf="generalFormGroup.value.type === 'classroom' || generalFormGroup.value.type === 'clients-group'" [formGroup]="networkSettingsFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'ogLiveLabel' | translate }}</mat-label>
<mat-select formControlName="ogLive" (selectionChange)="onOgLiveChange($event)">
<mat-option *ngFor="let oglive of ogLives" [value]="oglive['@id']">
{{ oglive.filename }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'repositoryLabel' | translate }}</mat-label>
<mat-select formControlName="repository" (selectionChange)="onRepositoryChange($event)">
<mat-option *ngFor="let repository of repositories" [value]="repository['@id']">
{{ repository.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'proxyUrlLabel' | translate }}</mat-label>
<input matInput formControlName="proxy">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'dnsIpLabel' | translate }}</mat-label>
<input matInput formControlName="dns">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'netmaskLabel' | translate }}</mat-label>
<input matInput formControlName="netmask">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'routerLabel' | translate }}</mat-label>
<input matInput formControlName="router">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'ntpIpLabel' | translate }}</mat-label>
<input matInput formControlName="ntp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pModeLabel' | translate }}</mat-label>
<mat-select formControlName="p2pMode">
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value">{{ option.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pTimeLabel' | translate }}</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastIpLabel' | translate }}</mat-label>
<input matInput formControlName="mcastIp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastSpeedLabel' | translate }}</mat-label>
<input matInput formControlName="mcastSpeed" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastPortLabel' | translate }}</mat-label>
<input matInput formControlName="mcastPort" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'mcastModeLabel' | translate }}</mat-label>
<mat-select formControlName="mcastMode">
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value">{{ option.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'menuUrlLabel' | translate }}</mat-label>
<input matInput formControlName="menu" type="url">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'hardwareProfileLabel' | translate }}</mat-label>
<mat-select formControlName="hardwareProfile">
<mat-option *ngFor="let unit of hardwareProfiles" [value]="unit['@id']">{{ unit.description }}</mat-option>
</mat-select>
<mat-error>{{ 'urlFormatError' | translate }}</mat-error>
</mat-form-field>
<mat-slide-toggle formControlName="validation">{{ 'validationToggle' | translate }}</mat-slide-toggle>
</form>
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onNoClick()">{{ 'cancelButton' | translate }}</button>
<button mat-button (click)="onSubmit()" [disabled]="!networkSettingsFormGroup.valid">{{ 'submitButton' | translate }}</button>
<button mat-button (click)="onSubmit()" [disabled]="!networkSettingsFormGroup.valid">{{ 'editOUSubmitButton' | translate }}</button>
</div>

View File

@ -19,16 +19,23 @@ export class EditOrganizationalUnitComponent implements OnInit {
networkSettingsFormGroup: FormGroup;
classroomInfoFormGroup: FormGroup;
types: string[] = ['organizational-unit', 'classrooms-group', 'classroom', 'clients-group'];
typeTranslations: { [key: string]: string } = {
'organizational-unit': 'Centro',
'classrooms-group': 'Grupo de aulas',
'classroom': 'Aula',
'clients-group': 'Grupo de clientes'
};
parentUnits: any[] = [];
hardwareProfiles: any[] = [];
isEditMode: boolean;
currentCalendar: any = [];
ogLives: any[] = [];
repositories: any[] = [];
parentUnitsWithPaths: { id: string, name: string, path: string }[] = [];
protected p2pModeOptions = [
{"name": 'Leecher', "value": "p2p-mode-leecher"},
{"name": 'Peer', "value": "p2p-mode-peer"},
{"name": 'Seeder', "value": "p2p-mode-seeder"},
{"name": 'Leecher', "value": "leecher"},
{"name": 'Peer', "value": "peer"},
{"name": 'Seeder', "value": "seeder"},
];
protected multicastModeOptions = [
{"name": 'Half duplex', "value": "half"},
@ -103,18 +110,25 @@ export class EditOrganizationalUnitComponent implements OnInit {
}
loadParentUnits() {
const url = `${this.baseUrl}/organizational-units?page=1&itemsPerPage=10000`;
const url = `${this.baseUrl}/organizational-units?page=1&itemsPerPage=1000`;
this.http.get<any>(url).subscribe(
response => {
this.parentUnits = response['hydra:member'];
this.parentUnitsWithPaths = this.parentUnits.map(unit => ({
id: unit['@id'],
name: unit.name,
path: this.dataService.getOrganizationalUnitPath(unit, this.parentUnits)
}));
},
error => {
console.error('Error fetching parent units:', error);
}
error => console.error('Error fetching parent units:', error)
);
}
getSelectedParentName(): string | undefined {
const parentId = this.generalFormGroup.get('parent')?.value;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
loadHardwareProfiles(): void {
this.dataService.getHardwareProfiles().subscribe(
(data: any[]) => {
@ -266,7 +280,7 @@ export class EditOrganizationalUnitComponent implements OnInit {
},
error => {
console.error('Error al realizar POST:', error);
this.toastService.error('Error al editar:', error);
this.toastService.error('Error al editar:', error.error['hydra:description']);
}
);
}

View File

@ -1,40 +0,0 @@
mat-content {
padding: 20px;
}
.item-content {
display: flex;
width: 100%;
padding: 10px;
}
.item-content mat-icon {
margin-right: 10px;
}
.tree-invisible {
display: none;
}
.tree ul,
.tree li {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
}
/*
* This padding sets alignment of the nested nodes.
*/
.tree .mat-nested-tree-node div[role=group] {
padding-left: 40px;
}
/*
* Padding for leaf nodes.
* Leaf nodes need to have padding so as to align with other non-leaf nodes
* under the same parent.
*/
.tree div[role=group] > .mat-tree-node {
padding-left: 40px;
}

View File

@ -1,54 +0,0 @@
<h1 mat-dialog-title>{{ 'viewTreeTitle' | translate }}</h1>
<mat-dialog-content>
<mat-tree [dataSource]="dataSource" [treeControl]="treeControl" class="tree">
<mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle>
<mat-icon [ngSwitch]="node.type">
<ng-container *ngSwitchCase="'organizational-unit'">apartment</ng-container>
<ng-container *ngSwitchCase="'classrooms-group'">meeting_room</ng-container>
<ng-container *ngSwitchCase="'classroom'">school</ng-container>
<ng-container *ngSwitchCase="'client'">computer</ng-container>
<ng-container *ngSwitchCase="'clients-group'">lan</ng-container>
<ng-container *ngSwitchDefault>help_outline</ng-container>
</mat-icon>
{{ node.name }}
</mat-tree-node>
<!-- Nodo expandible -->
<mat-nested-tree-node *matTreeNodeDef="let node; when: hasChild">
<div class="mat-tree-node">
<button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + node.name | translate">
<mat-icon class="mat-icon-rtl-mirror">
{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
</mat-icon>
</button>
<div class="item-content">
<mat-icon [ngSwitch]="node.type">
<ng-container *ngSwitchCase="'organizational-unit'">apartment</ng-container>
<ng-container *ngSwitchCase="'classrooms-group'">meeting_room</ng-container>
<ng-container *ngSwitchCase="'classroom'">school</ng-container>
<ng-container *ngSwitchCase="'client'">computer</ng-container>
<ng-container *ngSwitchCase="'clients-group'">lan</ng-container>
<ng-container *ngSwitchDefault>help_outline</ng-container>
</mat-icon>
{{ node.name }}
</div>
</div>
<div [class.tree-invisible]="!treeControl.isExpanded(node)" role="group">
<ng-container matTreeNodeOutlet></ng-container>
<mat-list *ngIf="node.clients">
<mat-list-item *ngFor="let client of node.clients">
<mat-icon matListItemIcon>computer</mat-icon>
<span matListItemTitle>{{ client.name }}</span>
<span>{{ client.ip }} | {{ client.mac }}</span>
</mat-list-item>
</mat-list>
</div>
</mat-nested-tree-node>
</mat-tree>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="close()">{{ 'closeButton' | translate }}</button>
</mat-dialog-actions>

View File

@ -1,70 +0,0 @@
import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {NestedTreeControl} from "@angular/cdk/tree";
import {MatTreeNestedDataSource} from "@angular/material/tree";
interface OrganizationalUnit {
id: number;
name: string;
type: string;
clients?: Client[];
children?: OrganizationalUnit[];
}
interface Client {
id: number;
name: string;
ip: string;
mac: string;
serialNumber: string;
}
@Component({
selector: 'app-tree-view',
templateUrl: './tree-view.component.html',
styleUrl: './tree-view.component.css'
})
export class TreeViewComponent implements OnInit {
treeControl = new NestedTreeControl<OrganizationalUnit>(node => node.children);
dataSource = new MatTreeNestedDataSource<OrganizationalUnit>();
constructor(
private dialogRef: MatDialogRef<TreeViewComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {
}
ngOnInit() {
this.dataSource.data = [this.mapData(this.data.data)];
}
hasChild = (_: number, node: OrganizationalUnit) => (!!node.children && node.children.length > 0 || !!node.clients && node.clients.length > 0);
private mapData(data: any): OrganizationalUnit {
const mapClients = (clients: any[]): Client[] => {
console.log(clients)
return clients.map(client => ({
id: client.id,
name: client.name,
ip: client.ip,
mac: client.mac,
serialNumber: client.serialNumber,
}));
};
const mapChildren = (children: any[]): OrganizationalUnit[] => {
return children.map(child => this.mapData(child));
};
return {
id: data.id,
name: data.name,
type: data.type,
clients: data.clients ? mapClients(data.clients) : [],
children: data.children ? mapChildren(data.children) : []
};
}
close(): void {
this.dialogRef.close();
}
}

View File

@ -42,6 +42,13 @@
{{ 'remotePcLabel' | translate }}
</mat-checkbox>
<mat-checkbox
formControlName="isGlobal"
class="example-margin"
>
{{ 'globalImageLabel' | translate }}
</mat-checkbox>
<mat-divider *ngIf="imageId && partitionInfo"></mat-divider>
<div *ngIf="imageId && partitionInfo" class="partition-info-container">

View File

@ -30,6 +30,7 @@ export class CreateImageComponent implements OnInit {
description: [''],
comments: [''],
remotePc: [false],
isGlobal: [false],
softwareProfile: [''],
imageRepository: ['', Validators.required],
});
@ -51,6 +52,7 @@ export class CreateImageComponent implements OnInit {
description: [response.description],
comments: [response.comments],
remotePc: [response.remotePc],
isGlobal: [response.isGlobal],
softwareProfile: [response.softwareProfile ? response.softwareProfile['@id'] : null, Validators.required],
imageRepository: [response.imageRepository ? response.imageRepository['@id'] : null, Validators.required],
});
@ -90,16 +92,12 @@ export class CreateImageComponent implements OnInit {
}
saveImage(): void {
if (this.imageForm.invalid) {
this.toastService.error('Por favor, rellena los campos obligatorios');
return;
}
const payload: any = {
name: this.imageForm.value.name,
description: this.imageForm.value.description,
comments: this.imageForm.value.comments,
remotePc: this.imageForm.value.remotePc,
isGlobal: this.imageForm.value.isGlobal,
imageRepository: this.imageForm.value.imageRepository,
...(this.imageForm.value.softwareProfile ? { softwareProfile: this.imageForm.value.softwareProfile } : {}),
};

View File

@ -0,0 +1,22 @@
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
}
mat-form-field {
width: 100%;
}
mat-dialog-actions {
display: flex;
justify-content: flex-end;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,15 @@
<h2 mat-dialog-title>Exportar imagen {{data.image?.name}}</h2>
<mat-dialog-content>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione repositorio destino</mat-label>
<mat-select [(value)]="selectedRepository">
<mat-option *ngFor="let repository of repositories" [value]="repository['@id']">{{ repository.name }}</mat-option>
</mat-select>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="close()">Cancelar</button>
<button mat-button (click)="save()">Continuar</button>
</mat-dialog-actions>

View File

@ -0,0 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExportImageComponent } from './export-image.component';
import {FormBuilder, ReactiveFormsModule} from "@angular/forms";
import {ToastrModule, ToastrService} from "ngx-toastr";
import {provideHttpClient} from "@angular/common/http";
import {provideHttpClientTesting} from "@angular/common/http/testing";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatButtonModule} from "@angular/material/button";
import {MatSelectModule} from "@angular/material/select";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {TranslateModule} from "@ngx-translate/core";
describe('ExportImageComponent', () => {
let component: ExportImageComponent;
let fixture: ComponentFixture<ExportImageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ExportImageComponent],
imports: [
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatSelectModule,
BrowserAnimationsModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
],
providers: [
FormBuilder,
ToastrService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {}
}]
})
.compileComponents();
fixture = TestBed.createComponent(ExportImageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,62 @@
import {Component, Inject, OnInit} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ToastrService} from "ngx-toastr";
import {Router} from "@angular/router";
@Component({
selector: 'app-export-image',
templateUrl: './export-image.component.html',
styleUrl: './export-image.component.css'
})
export class ExportImageComponent implements OnInit {
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
loading: boolean = true;
repositories: any[] = [];
selectedRepository: string = '';
constructor(
private http: HttpClient,
public dialogRef: MatDialogRef<ExportImageComponent>,
private toastService: ToastrService,
private router: Router,
@Inject(MAT_DIALOG_DATA) public data: { image: any }
) {
}
ngOnInit(): void {
this.loading = true;
this.loadRepositories();
}
loadRepositories() {
this.http.get<any>(`${this.baseUrl}/image-repositories?id[ne]=1&page=1&itemsPerPage=50`).subscribe(
response => {
this.repositories = response['hydra:member'];
this.loading = false;
},
error => console.error('Error fetching organizational units:', error)
);
}
save() {
this.http.post<any>(`${this.baseUrl}${this.selectedRepository}/export-image`, {
images: [this.data.image['@id']]
}).subscribe({
next: (response) => {
this.toastService.success('Imagen exportada correctamente');
this.dialogRef.close();
this.router.navigate(['/commands-logs']);
},
error: error => {
console.error('Error al exportar imagen:', error);
this.toastService.error('Error al exportar imagen');
}
});
}
close() {
this.dialogRef.close();
}
}

View File

@ -91,3 +91,14 @@ table {
color: white;
}
.chip-transferring {
background-color: #f5a623 !important;
color: white;
}
.header-container-title {
flex-grow: 1;
text-align: left;
margin-left: 1em;
}

View File

@ -3,7 +3,11 @@
<button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon>
</button>
<h2 class="title">{{ 'imagesTitle' | translate }}</h2>
<div class="header-container-title">
<h2 joyrideStep="groupsTitleStepText" text="{{ 'groupsTitleStepText' | translate }}">
{{ 'imagesTitle' | translate }}
</h2>
</div>
<div class="images-button-row">
<button mat-flat-button color="primary" (click)="addImage()">
{{ 'addImageButton' | translate }}
@ -31,17 +35,23 @@
{{ image[column.columnDef] ? 'check_circle' : 'cancel' }}
</mat-icon>
</ng-container>
<ng-container *ngIf="column.columnDef === 'isGlobal'">
<mat-icon [color]="image[column.columnDef] ? 'primary' : 'warn'">
{{ image[column.columnDef] ? 'check_circle' : 'cancel' }}
</mat-icon>
</ng-container>
<ng-container *ngIf="column.columnDef === 'status'">
<mat-chip [ngClass]="{
'chip-failed': image.status === 'failed',
'chip-success': image.status === 'success',
'chip-pending': image.status === 'pending',
'chip-in-progress': image.status === 'in-progress'
'chip-in-progress': image.status === 'in-progress',
'chip-transferring': image.status === 'transferring',
}">
{{ getStatusLabel(image[column.columnDef]) }}
</mat-chip>
</ng-container>
<ng-container *ngIf="column.columnDef !== 'remotePc' && column.columnDef !== 'status'">
<ng-container *ngIf="column.columnDef !== 'remotePc' && column.columnDef !== 'status' && column.columnDef !== 'isGlobal'">
{{ column.cell(image) }}
</ng-container>
</td>
@ -61,8 +71,9 @@
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="toggleAction(image, 'get-aux')">Obtener ficheros auxiliares</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'" (click)="toggleAction(image, 'delete-trash')">Eliminar imagen temporalmente</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'" (click)="toggleAction(image, 'delete-permanent')">Eliminar imagen</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'trash'" (click)="toggleAction(image, 'recover')">Recuperar imagen de la papelera</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'" (click)="toggleAction(image, 'export')">Exportar imagen</button>
</mat-menu>
</td>
</ng-container>

View File

@ -1,16 +1,15 @@
import { Component, OnInit } from '@angular/core';
import {Component, Input, OnInit} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { HttpClient } from '@angular/common/http';
import { MatTableDataSource } from '@angular/material/table';
import { ToastrService } from 'ngx-toastr';
import { DatePipe } from '@angular/common';
import { CreateImageComponent } from './create-image/create-image.component';
import {CreateCommandComponent} from "../commands/main-commands/create-command/create-command.component";
import {DeleteModalComponent} from "../../shared/delete_modal/delete-modal/delete-modal.component";
import {ServerInfoDialogComponent} from "../ogdhcp/og-dhcp-subnets/server-info-dialog/server-info-dialog.component";
import {Observable} from "rxjs";
import {InfoImageComponent} from "../ogboot/pxe-images/info-image/info-image/info-image.component";
import { JoyrideService } from 'ngx-joyride';
import {ExportImageComponent} from "./export-image/export-image.component";
@Component({
selector: 'app-images',
@ -48,6 +47,11 @@ export class ImagesComponent implements OnInit {
header: 'Remote Pc',
cell: (image: any) => `${image.remotePc}`
},
{
columnDef: 'isGlobal',
header: 'Imagen Global',
cell: (image: any) => `${image.isGlobal}`
},
{
columnDef: 'status',
header: 'Estado',
@ -67,16 +71,34 @@ export class ImagesComponent implements OnInit {
displayedColumns = [...this.columns.map(column => column.columnDef), 'actions'];
private apiUrl = `${this.baseUrl}/images`;
@Input() repositoryUuid: any
private repositoryId: any;
constructor(
public dialog: MatDialog,
private http: HttpClient,
private toastService: ToastrService,
private joyrideService: JoyrideService
private joyrideService: JoyrideService,
) {}
ngOnInit(): void {
this.search();
if (this.repositoryUuid) {
this.loadRepository()
} else {
this.search();
}
}
loadRepository(): void {
this.http.get<any>(`${this.baseUrl}/image-repositories/${this.repositoryUuid}`, {}).subscribe(
data => {
this.repositoryId = data.id;
this.search();
},
error => {
console.error('Error fetching image repositories', error);
}
)
}
getStatusLabel(status: string): string {
@ -110,7 +132,7 @@ export class ImagesComponent implements OnInit {
search(): void {
this.loading = true;
this.http.get<any>(`${this.apiUrl}?page=${this.page +1 }&itemsPerPage=${this.itemsPerPage}`, { params: this.filters }).subscribe(
this.http.get<any>(`${this.apiUrl}?page=${this.page +1 }&itemsPerPage=${this.itemsPerPage}&repository.id=${this.repositoryId}`, { params: this.filters }).subscribe(
data => {
this.dataSource.data = data['hydra:member'];
this.length = data['hydra:totalItems'];
@ -205,6 +227,17 @@ export class ImagesComponent implements OnInit {
}
});
break;
case 'delete-permanent':
this.http.post(`${this.baseUrl}/images/server/${image.uuid}/delete-permanent`, {}).subscribe({
next: () => {
this.toastService.success('Petición de eliminación de la papelera temporal enviada');
this.search()
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
break;
case 'recover':
this.http.post(`${this.baseUrl}/images/server/${image.uuid}/recover`, {}).subscribe({
next: () => {
@ -216,6 +249,14 @@ export class ImagesComponent implements OnInit {
}
});
break;
case 'export':
this.dialog.open(ExportImageComponent, {
width: '600px',
data: {
image: image
}
});
break;
default:
console.error('Acción no soportada:', action);
break;

View File

@ -0,0 +1,39 @@
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
}
mat-form-field {
width: 100%;
}
mat-dialog-actions {
display: flex;
justify-content: flex-end;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.selected-list ul {
list-style: none;
padding: 0;
}
.selected-item {
display: flex;
justify-content: space-between; /* Alinea texto a la izquierda y botón a la derecha */
align-items: center; /* Centra verticalmente */
padding: 8px;
border-bottom: 1px solid #ccc;
}
.selected-item button {
margin-left: 10px;
}

View File

@ -0,0 +1,28 @@
<h2 mat-dialog-title>Importar imagenes a {{data.repository?.name}}</h2>
<mat-dialog-content>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione imagenes a importar</mat-label>
<mat-select [(value)]="selectedClients" multiple>
<mat-option *ngFor="let image of images" [value]="image['@id']">{{ image.name }}</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="selectedClients.length > 0" class="selected-list">
<h3>Imágenes seleccionadas:</h3>
<ul>
<li *ngFor="let imageId of selectedClients" class="selected-item">
<span>{{ getImageName(imageId) }}</span>
<button mat-icon-button color="warn" (click)="removeImage(imageId)">
<mat-icon>delete</mat-icon>
</button>
</li>
</ul>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="close()">Cancelar</button>
<button mat-button (click)="save()">Continuar</button>
</mat-dialog-actions>

View File

@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImportImageComponent } from './import-image.component';
import {FormBuilder, ReactiveFormsModule} from "@angular/forms";
import {ToastrModule, ToastrService} from "ngx-toastr";
import {DataService} from "../../calendar/data.service";
import {provideHttpClient} from "@angular/common/http";
import {provideHttpClientTesting} from "@angular/common/http/testing";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatFormFieldModule} from "@angular/material/form-field";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {TranslateModule} from "@ngx-translate/core";
import {MatButtonModule} from "@angular/material/button";
import {MatInputModule} from "@angular/material/input";
import {MatSelectModule} from "@angular/material/select";
describe('ImportImageComponent', () => {
let component: ImportImageComponent;
let fixture: ComponentFixture<ImportImageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ImportImageComponent],
imports: [
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatSelectModule,
BrowserAnimationsModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
],
providers: [
FormBuilder,
ToastrService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {}
}]
})
.compileComponents();
fixture = TestBed.createComponent(ImportImageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,70 @@
import {Component, Inject, OnInit} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ToastrService} from "ngx-toastr";
import {Router} from "@angular/router";
@Component({
selector: 'app-import-image',
templateUrl: './import-image.component.html',
styleUrl: './import-image.component.css'
})
export class ImportImageComponent implements OnInit{
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
loading: boolean = true;
images: any[] = [];
selectedClients: any[] = [];
constructor(
private http: HttpClient,
public dialogRef: MatDialogRef<ImportImageComponent>,
private toastService: ToastrService,
private router: Router,
@Inject(MAT_DIALOG_DATA) public data: { repository: any }
) {
}
ngOnInit(): void {
this.loading = true;
this.loadImages();
}
loadImages() {
this.http.get<any>(`${this.baseUrl}/images?page=1&itemsPerPage=50`).subscribe(
response => {
this.images = response['hydra:member'];
this.loading = false;
},
error => console.error('Error fetching organizational units:', error)
);
}
getImageName(imageId: string): string {
const image = this.images.find(img => img['@id'] === imageId);
return image ? image.name : 'Desconocido';
}
removeImage(imageId: string) {
this.selectedClients = this.selectedClients.filter(id => id !== imageId);
}
save() {
this.http.post<any>(`${this.baseUrl}${this.data.repository['@id']}/import-image`, {
images: this.selectedClients
}).subscribe({
next: (response) => {
this.toastService.success('Peticion de importacion de imagen enviada correctamente');
this.dialogRef.close();
this.router.navigate(['/commands-logs']);
},
error: error => {
this.toastService.error('Error al importar imagenes');
}
});
}
close() {
this.dialogRef.close();
}
}

View File

@ -135,6 +135,10 @@
min-height: 400px;
}
.main-container {
margin-top: 15px;
}
.mat-tab-body-wrapper {
min-height: inherit;
}
@ -210,13 +214,6 @@ p {
align-items: center;
}
.status-led {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 10px;
}
.status-led.active {
background-color: green;
@ -304,4 +301,85 @@ table {
}
.dashboard {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
.top-row {
display: flex;
justify-content: center;
gap: 20px;
}
.top-row .card {
flex: 1;
}
.card {
background: white;
padding: 15px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
flex: 1;
min-width: 300px;
}
.status-led {
width: 10px;
height: 10px;
display: inline-block;
border-radius: 50%;
margin-right: 5px;
}
.active {
background-color: green;
}
.inactive {
background-color: red;
}
.cpu-usage-bar {
background: lightgray;
width: 100%;
height: 20px;
border-radius: 5px;
overflow: hidden;
}
.cpu-bar {
height: 100%;
background: green;
}
.cpu-bar.high {
background: red;
}
@media (max-width: 900px) {
.top-row {
flex-direction: column;
}
.top-row .card {
max-width: 100%;
}
}

View File

@ -1,51 +1,91 @@
<mat-tab-group dynamicHeight>
<mat-tab-group class="main-container" dynamicHeight>
<mat-tab label="Estado servidor">
<div class="dashboard">
<h2>OgRepository server Status</h2>
<div class="disk-usage-container">
<div class="disk-usage">
<h3>Uso de disco</h3>
<h2>OgRepository Server Status</h2>
<div class="row top-row">
<div class="card">
<h3>Uso de Disco</h3>
<ngx-charts-pie-chart
[view]="view"
[scheme]="colorScheme"
[results]="diskUsageChartData"
[gradient]="gradient"
[doughnut]="isDoughnut"
[labels]="showLabels"
[legend]="showLegend">
[labels]="showLabels" >
</ngx-charts-pie-chart>
<div class="disk-usage-info">
<div class="info">
<p>Total: {{ diskUsage.total }}</p>
<p>Ocupado: {{ diskUsage.used }}</p>
<p>Disponible: {{ diskUsage.available }}</p>
<p>Ocupado ( % ): {{ diskUsage.percentage }}</p>
<p>Ocupado (%): {{ diskUsage.percentage }}</p>
</div>
</div>
<div class="services-status">
<div class="card">
<h3>Uso de RAM</h3>
<ngx-charts-pie-chart
[view]="view"
[scheme]="colorScheme"
[results]="ramUsageChartData"
[gradient]="gradient"
[doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
<div class="info">
<p>Total: {{ ramUsage.total }}</p>
<p>Ocupado: {{ ramUsage.used }}</p>
<p>Disponible: {{ ramUsage.available }}</p>
<p>Ocupado (%): {{ ramUsage.percentage }}</p>
</div>
</div>
</div>
<div class="row bottom-row">
<div class="card">
<h3>Uso de CPU</h3>
<div class="cpu-usage-bar">
<div class="cpu-bar" [style.width]="cpuUsage.percentage" [ngClass]="{'high': cpuUsage.percentage > '80%'}"></div>
</div>
<p>Usado: {{ cpuUsage.percentage }}</p>
</div>
<div class="card">
<h3>Servicios</h3>
<ul>
<li *ngFor="let service of getServices()">
<span
class="status-led"
[ngClass]="{
'active': service.status === 'active',
'inactive': service.status === 'stopped' || service.status === 'status not accesible'
}"
></span>
<span class="status-led" [ngClass]="{
'active': service.status === 'active',
'inactive': service.status === 'stopped' || service.status === 'status not accesible'
}"></span>
{{ service.name }}:
<span [ngSwitch]="service.status">
<span *ngSwitchCase="'active'">Activo</span>
<span *ngSwitchCase="'stopped'">Detenido</span>
<span *ngSwitchCase="'status not accesible'">No accesible</span>
<span *ngSwitchDefault>{{ service.status }}</span>
</span>
<span *ngSwitchCase="'active'">Activo</span>
<span *ngSwitchCase="'stopped'">Detenido</span>
<span *ngSwitchCase="'status not accesible'">No accesible</span>
<span *ngSwitchDefault>{{ service.status }}</span>
</span>
</li>
</ul>
</div>
<div class="card">
<h3>Procesos</h3>
<ul>
<li *ngFor="let process of getProcesses()">
<span class="status-led" [ngClass]="{
'active': process.status === 'running',
'inactive': process.status === 'stopped'
}"></span>
{{ process.name }}: {{ process.status }}
</li>
</ul>
</div>
</div>
</div>
</mat-tab>
<mat-tab label="Datos generales">
<div class="dashboard">
<div class="header-button-container">
@ -83,59 +123,6 @@
</mat-tab>
<mat-tab label="Listado de imágenes">
<div class="dashboard">
<h2>Imágenes</h2>
<div class="search-container">
<mat-form-field appearance="fill" class="search-string">
<mat-label>Buscar nombre de imagen</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['name']" (keyup.enter)="searchImages()" i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<mat-hint>Pulsar 'enter' para buscar</mat-hint>
</mat-form-field>
</div>
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image" >
<ng-container *ngIf="column.columnDef === 'remotePc' || column.columnDef === 'created'">
<mat-icon [color]="image[column.columnDef] ? 'primary' : 'warn'">
{{ image[column.columnDef] ? 'check_circle' : 'cancel' }}
</mat-icon>
</ng-container>
<ng-container *ngIf="column.columnDef !== 'remotePc' && column.columnDef !== 'created'">
{{ column.cell(image) }}
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: center;">Acciones</th>
<td mat-cell *matCellDef="let image" style="text-align: center;">
<button mat-icon-button color="info" (click)="showImageInfo($event, image)"><mat-icon i18n="@@deleteElementTooltip">visibility</mat-icon></button>
<button mat-icon-button color="primary" (click)="editImage($event, image)" i18n="@@editImage"> <mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="toggleAction(image, 'delete')">
<mat-icon i18n="@@deleteElementTooltip">delete</mat-icon>
</button>
<button mat-icon-button [matMenuTriggerFor]="menu">
<mat-icon>menu</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item [disabled]="!image.imageFullsum" (click)="toggleAction(image, 'get-aux')">Obtener ficheros auxiliares</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<div class="paginator-container">
<mat-paginator [length]="length"
[pageSize]="itemsPerPage"
[pageIndex]="page"
[pageSizeOptions]="[5, 10, 20, 40, 100]"
(page)="onPageChange($event)">
</mat-paginator>
</div>
</div>
<app-images [repositoryUuid]="repositoryId"></app-images>
</mat-tab>
</mat-tab-group>

View File

@ -1,4 +1,4 @@
import {Component, Inject} from '@angular/core';
import {Component, Inject, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from "@angular/forms";
import {HttpClient} from "@angular/common/http";
import {ToastrService} from "ngx-toastr";
@ -17,7 +17,7 @@ import {MatDialog} from "@angular/material/dialog";
templateUrl: './main-repository-view.component.html',
styleUrl: './main-repository-view.component.css'
})
export class MainRepositoryViewComponent {
export class MainRepositoryViewComponent implements OnInit {
baseUrl: string = import.meta.env.NG_APP_BASE_API_URL;
repositoryForm: FormGroup<any>;
repositoryId: string | null = null;
@ -25,16 +25,20 @@ export class MainRepositoryViewComponent {
loading: boolean = true;
diskUsage: any = {};
servicesStatus: any = {};
processesStatus: any = {};
diskUsageChartData: any[] = [];
ramUsageChartData: any[] = [];
ramUsage: any = {};
cpuUsage: any = {};
alertMessage: string | null = null;
length: number = 0;
itemsPerPage: number = 10;
page: number = 0;
view: [number, number] = [800, 500];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = true;
status: boolean = false;
repositoryData: any = {};
colorScheme: any = {
domain: ['#FF6384', '#3f51b5']
@ -115,7 +119,6 @@ export class MainRepositoryViewComponent {
comments: [response.comments],
});
this.loading = false;
// Llamar searchImages() solo cuando la data de repository esté cargada
this.searchImages();
},
(error) => {
@ -157,28 +160,33 @@ export class MainRepositoryViewComponent {
loadStatus(): void {
this.http.get<any>(`${this.baseUrl}/image-repositories/server/${this.repositoryId}/status`).subscribe(data => {
const diskData = data.output.disk;
const servicesData = data.output.services;
if (!data.success) {
console.error('Error: No se pudo obtener los datos del servidor');
this.status = false;
return;
}
this.diskUsage = {
total: diskData.total,
used: diskData.used,
available: diskData.available,
percentage: diskData.used_percentage
};
this.status = true;
const { disk, services, ram, cpu, processes } = data.output;
this.diskUsage = { ...disk };
this.diskUsageChartData = [
{
name: 'Usado',
value: parseFloat(diskData.used)
},
{
name: 'Disponible',
value: parseFloat(diskData.available)
}
{ name: 'Usado', value: parseFloat(disk.used.replace('GB', '')) },
{ name: 'Disponible', value: parseFloat(disk.available.replace('GB', '')) }
];
this.servicesStatus = servicesData;
this.ramUsage = { ...ram };
this.ramUsageChartData = [
{ name: 'Usado', value: parseFloat(ram.used.replace('GB', '')) },
{ name: 'Disponible', value: parseFloat(ram.available.replace('GB', '')) }
];
this.cpuUsage = { percentage: cpu.used_percentage };
this.servicesStatus = Object.entries(services).map(([name, status]) => ({ name, status }));
this.processesStatus = Object.entries(processes).map(([name, status]) => ({ name, status }));
}, error => {
console.error('Error fetching status', error);
@ -186,10 +194,17 @@ export class MainRepositoryViewComponent {
}
getServices(): { name: string, status: string }[] {
return Object.keys(this.servicesStatus).map(key => ({
name: key,
status: this.servicesStatus[key]
}));
if (!this.status) {
return [];
}
return this.servicesStatus ? this.servicesStatus : [];
}
getProcesses(): { name: string, status: string }[] {
if (!this.status) {
return [];
}
return this.processesStatus ? this.processesStatus : [];
}
searchImages(): void {
@ -207,64 +222,6 @@ export class MainRepositoryViewComponent {
);
}
editImage(event: MouseEvent, image: any): void {
event.stopPropagation();
this.dialog.open(CreateImageComponent, {
width: '800px',
data: image['@id']
}).afterClosed().subscribe(() => this.searchImages());
}
deleteImage(image: any): void {
this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: image.name },
}).afterClosed().subscribe((result) => {
if (result) {
this.http.delete(`${this.apiUrl}/server/${image.uuid}/delete`).subscribe({
next: () => {
this.toastService.success('Imagen eliminada con éxito');
this.searchImages();
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
console.error('Error al eliminar la imagen:', error);
}
});
}
});
}
loadImageAlert(image: any): Observable<any> {
return this.http.get<any>(`${this.apiUrl}/server/${image.uuid}/get`, {});
}
showImageInfo(event: MouseEvent, image:any) {
event.stopPropagation();
this.loadImageAlert(image).subscribe(
response => {
this.alertMessage = response.output;
this.dialog.open(ServerInfoDialogComponent, {
width: '600px',
data: {
message: this.alertMessage
}
});
},
error => {
this.toastService.error(error.error['hydra:description']);
}
);
}
onPageChange(event: any): void {
this.page = event.pageIndex;
this.itemsPerPage = event.pageSize;
this.length = event.length;
this.searchImages();
}
loadAlert(): Observable<any> {
return this.http.get<any>(`${this.baseUrl}/image-repositories/server/get-collection`);
}
@ -280,29 +237,6 @@ export class MainRepositoryViewComponent {
});
}
toggleAction(image: any, action:string): void {
switch (action) {
case 'get-aux':
this.http.post(`${this.baseUrl}/images/server/${image.uuid}/create-aux-files`, {}).subscribe({
next: () => {
this.toastService.success('Petición de creación de archivos auxiliares enviada');
this.searchImages()
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
break;
case 'delete':
this.deleteImage(image);
break;
default:
console.error('Acción no soportada:', action);
break;
}
}
openImageInfoDialog() {
this.loadAlert().subscribe(
response => {

View File

@ -100,3 +100,10 @@ table {
margin: 8px 8px 8px 0;
}
.header-container-title {
flex-grow: 1;
text-align: left;
margin-left: 1em;
}

View File

@ -2,7 +2,11 @@
<button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon>
</button>
<h2 class="title" joyrideStep="titleStep" text="Desde esta pantalla podrás ver y administrar los respositioros exitentes.">Administrar repositorios</h2>
<div class="header-container-title">
<h2 joyrideStep="groupsTitleStepText" text="{{ 'groupsTitleStepText' | translate }}">
{{ 'repositoryTitle' | translate }}
</h2>
</div>
<div class="images-button-row">
<button mat-flat-button color="primary" (click)="addImage()" joyrideStep="addStep" text="Utiliza este botón para añadir un nuevo repositorio.">Añadir repositorio</button>
</div>
@ -11,14 +15,20 @@
<div class="search-container">
<mat-form-field appearance="fill" class="search-string">
<mat-label>Buscar nombre de imagen</mat-label>
<mat-label>Buscar nombre de repositorio</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['name']" (keyup.enter)="search()" i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<mat-hint>Pulsar 'enter' para buscar</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="search-string">
<mat-label>Buscar IP de repositorio</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['ip']" (keyup.enter)="search()" i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<mat-hint>Pulsar 'enter' para buscar</mat-hint>
</mat-form-field>
</div>
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let repository" >
@ -30,9 +40,10 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: center;">Acciones</th>
<td mat-cell *matCellDef="let client" style="text-align: center;">
<button mat-icon-button color="primary" (click)="editRepository($event, client)" i18n="@@editImage"> <mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="deleteRepository($event, client)">
<td mat-cell *matCellDef="let repository" style="text-align: center;">
<button mat-icon-button color="primary" (click)="importImage($event, repository)" i18n="@@editImage"> <mat-icon>move_to_inbox</mat-icon></button>
<button mat-icon-button color="primary" (click)="editRepository($event, repository)" i18n="@@editImage"> <mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="deleteRepository($event, repository)">
<mat-icon i18n="@@deleteElementTooltip">delete</mat-icon>
</button>
</td>

View File

@ -8,6 +8,7 @@ import {DeleteModalComponent} from "../../shared/delete_modal/delete-modal/delet
import { JoyrideService } from 'ngx-joyride';
import {CreateRepositoryComponent} from "./create-repository/create-repository.component";
import { Router } from '@angular/router';
import {ImportImageComponent} from "./import-image/import-image.component";
@Component({
selector: 'app-repositories',
@ -91,6 +92,16 @@ export class RepositoriesComponent {
this.router.navigate(['repository', repository.uuid]);
}
importImage(event: MouseEvent, repository: any): void {
event.stopPropagation();
this.dialog.open(ImportImageComponent, {
width: '600px',
data: { repository }
}).afterClosed().subscribe(() => {
this.search();
});
}
deleteRepository(event: MouseEvent,command: any): void {
event.stopPropagation();
this.dialog.open(DeleteModalComponent, {

View File

@ -4,15 +4,16 @@
.container {
width: 100vw;
height: calc(100vh - 10vh);
height: calc(100vh - 10vh);
}
.sidebar {
width: 250px;
width: 250px;
z-index: auto !important;
}
.content {
margin: 10px;
padding: 10px;
box-sizing: border-box;
}
margin: 0px 10px 10px 10px;
padding: 0px 10px 10px 10px;
box-sizing: border-box;
}

View File

@ -0,0 +1,12 @@
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 99999;
}

View File

@ -0,0 +1,3 @@
<div *ngIf="isLoading" class="overlay">
<mat-spinner></mat-spinner>
</div>

View File

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

View File

@ -0,0 +1,10 @@
import {Component, Input} from '@angular/core';
@Component({
selector: 'app-loading',
templateUrl: './loading.component.html',
styleUrl: './loading.component.css'
})
export class LoadingComponent {
@Input() isLoading: boolean = false;
}

View File

@ -156,6 +156,8 @@
"newOrganizationalUnitTooltip": "Open modal to create organizational units of any type (Center, Classroom, Classroom Group, or Client Group)",
"newOrganizationalUnitButton": "New Organizational Unit",
"newClientButton": "New Client",
"newSingleClientButton": "Add single client",
"newMultipleClientButton": "Add numerous clients",
"keyStepText": "The legend will show you the types of organizational units and their corresponding icons",
"legendButton": "Legend",
"unitStepText": "This is the section where 'Center' type organizational units will be displayed",
@ -215,7 +217,8 @@
"hardwareProfileLabel": "Hardware Profile",
"urlFormatError": "Invalid URL format.",
"validationToggle": "Validation",
"submitButton": "Add",
"addOUSubmitButton": "Add",
"editOUSubmitButton": "Edit",
"addOrgUnitTitle": "Add Organizational Unit",
"createOrgUnitparentLabel": "Parent organizational unit",
"noParentOption": "--",
@ -266,11 +269,13 @@
"diskUsedLabel": "Used",
"diskTotalLabel": "Total",
"diskImageAssistantTitle": "Disk image assistant",
"deployImage": "Deploy image",
"partitionColumn": "Partition",
"isoImageColumn": "ISO Image",
"ogliveColumn": "OgLive",
"selectImageOption": "Select image",
"selectOgLiveOption": "Select OgLive",
"repositoryTitle": "Admin Repository",
"saveAssociationsButton": "Save Associations",
"partitionAssistantTitle": "Partition assistant",
"diskSizeLabel": "Size",
@ -278,6 +283,8 @@
"partitionSizeColumn": "Size (MB)",
"usageColumn": "Usage (%)",
"formatColumn": "Format",
"remotePcLabel": "Remote PC",
"globalImageLabel": "Global image",
"ntfsOption": "NTFS",
"linuxOption": "LINUX",
"cacheOption": "CACHE",
@ -428,7 +435,7 @@
"addClientMenu": "Add client",
"filters": "Filters",
"searchClient": "Search client",
"searchTree": "Search in tree",
"searchTree": "Search organizational unit",
"filterByType": "Filter by type",
"all": "All",
"classroomsGroup": "Classroom groups",

View File

@ -157,6 +157,8 @@
"newOrganizationalUnitTooltip": "Abrir modal para crear unidades organizativas de cualquier tipo (Centro, Aula, Grupo de aulas o Grupo de clientes)",
"newOrganizationalUnitButton": "Nueva Unidad Organizativa",
"newClientButton": "Nuevo Cliente",
"newSingleClientButton": "Añadir cliente unitario",
"newMultipleClientButton": "Añadir clientes masivamente",
"keyStepText": "La leyenda te mostrará los tipos de unidades organizativas y sus iconos correspondientes",
"legendButton": "Leyenda",
"unitStepText": "Esta es la sección donde se mostrarán las unidades organizativas de tipo 'Centro'",
@ -215,7 +217,8 @@
"hardwareProfileLabel": "Perfil de Hardware",
"urlFormatError": "Formato de URL inválido.",
"validationToggle": "Validación",
"submitButton": "Añadir",
"addOUSubmitButton": "Añadir",
"editOUSubmitButton": "Editar",
"addOrgUnitTitle": "Añadir Unidad Organizativa",
"createOrgUnitparentLabel": "Unidad organizativa padre",
"noParentOption": "--",
@ -271,6 +274,7 @@
"isoImageColumn": "Imagen ISO",
"ogliveColumn": "OgLive",
"selectImageOption": "Seleccionar imagen",
"deployImage": "Desplegar imagen",
"selectOgLiveOption": "Seleccionar OgLive",
"saveAssociationsButton": "Guardar Asociaciones",
"partitionAssistantTitle": "Asistente de particionado",
@ -296,6 +300,9 @@
"internalUnits": "Unidades internas",
"noResultsMessage": "No hay resultados para mostrar.",
"imagesTitle": "Administrar imágenes",
"repositoryTitle": "Administrar repositorios",
"remotePcLabel": "Remote PC",
"globalImageLabel": "Imagen Global",
"addImageButton": "Añadir imagen",
"searchNameDescription": "Busca imágenes por nombre para encontrar rápidamente una imagen específica.",
"searchDefaultDescription": "Filtra las imágenes para mostrar solo las imágenes por defecto o no por defecto.",
@ -430,7 +437,7 @@
"addClientMenu": "Añadir cliente",
"filters": "Filtros",
"searchClient": "Buscar cliente",
"searchTree": "Buscar en árbol",
"searchTree": "Buscar unidad organizativa",
"filterByType": "Filtrar por tipo",
"all": "Todos",
"classroomsGroup": "Grupos de aulas",