feat: UI search filters, network speeds and createImage for data partitions

main
Juan Manuel Bardallo 2026-09-17 12:21:56 +02:00
parent 7d71ca92f3
commit 361b98d221
10 changed files with 236 additions and 139 deletions

View File

@ -66,7 +66,8 @@ export class CreateClientImageComponent implements OnInit{
}
isPartitionDisabled = (row: any): boolean => {
return !row.operativeSystem;
// Se debe permitir crear imagen de particiones de sólo datos, tan sólo evitamos partición CACHE
return row.filesystem === 'CACHE';
};
columns = [

View File

@ -1617,6 +1617,44 @@ mat-button-toggle-group {
height: 14px;
}
/* 2. MODIFICADORES DE COLOR (Fondo claro + Texto oscuro de la misma gama) */
/* 10 Gbps - Morado (Destaca como velocidad ultra-rápida/premium) */
.badge-net-speed.speed-10000 {
background-color: #f3e5f5; /* Purple 50 */
color: #7b1fa2; /* Purple 700 */
}
/* 2.5 Gbps / 5 Gbps - Azul (El que tú ya tenías) */
.badge-net-speed.speed-5000, .badge-net-speed.speed-2500 {
background-color: #e3f2fd; /* Blue 50 */
color: #1976d2; /* Blue 700 */
}
/* 1 Gbps - Verde (El estándar de red saludable) */
.badge-net-speed.speed-1000 {
background-color: #e8f5e9; /* Green 50 */
color: #388e3c; /* Green 700 */
}
/* 100 Mbps - Naranja (Aviso de enlace inferior/cuello de botella) */
.badge-net-speed.speed-100 {
background-color: #fff3e0; /* Orange 50 */
color: #f57c00; /* Orange 700 */
}
/* 10 Mbps - Rojo (Aviso crítico / Posible cable roto) */
.badge-net-speed.speed-10 {
background-color: #ffebee; /* Red 50 */
color: #d32f2f; /* Red 700 */
}
/* Desconectado / Down - Gris (Neutro) */
.badge-net-speed.speed-down {
background-color: #f5f5f5; /* Grey 100 */
color: #616161; /* Grey 700 */
}
.list-view-badge {
display: inline-flex;
margin-top: 0;

View File

@ -73,76 +73,7 @@
<div class="main-container">
<!-- FILTERS AND TREE VIEW -->
<div class="filters-and-tree-container">
<!-- Filters -->
<div class="filters-panel" joyrideStep="filtersPanelStep" text="{{ 'filtersPanelStepText' | translate }}">
<div class="filters-container">
<mat-form-field class="filter-form-field" appearance="outline">
<mat-label>{{ 'searchTree' | translate }}</mat-label>
<input matInput #treeSearchInput (input)="onTreeFilterInput($event)"
[placeholder]="'placeholderOrgUnitTypes' | translate" />
@if (treeSearchInput.value) {
<button mat-icon-button matSuffix [attr.aria-label]="'clearTreeSearch' | translate"
(click)="clearTreeSearch(treeSearchInput)">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field class="filter-form-field" appearance="outline">
<mat-label>{{ 'searchClient' | translate }}</mat-label>
<input matInput #clientSearchInput (input)="onClientFilterInput($event)"
[placeholder]="'searchNameIpStatusMac' | translate" />
@if (clientSearchInput.value) {
<button mat-icon-button matSuffix [attr.aria-label]="'clearClientSearch' | translate"
(click)="clearClientSearch(clientSearchInput)">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field class="form-field search-select" appearance="outline">
<mat-select placeholder="{{ 'searchState' | translate }}" #clientSearchStatusInput
(selectionChange)="onClientFilterStatusInput($event.value)">
@for (option of status; track option) {
<mat-option [value]="option.value">
{{ option.name }}
</mat-option>
}
</mat-select>
@if (clientSearchStatusInput.value) {
<button mat-icon-button matSuffix [attr.aria-label]="'clearTreeSearch' | translate"
(click)="clearStatusFilter($event, clientSearchStatusInput)">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<div class="direct-clients-toggle">
<mat-slide-toggle
[(ngModel)]="showOnlyDirectClients"
(change)="onDirectClientsToggleChange()"
matTooltip="{{ 'directClientsToggleTooltip' | translate }}"
matTooltipShowDelay="1000">
{{ 'directClientsToggle' | translate }}
</mat-slide-toggle>
</div>
<!-- Funcionalidad actualmente deshabilitada-->
<!-- <mat-form-field appearance="outline">
<mat-select (selectionChange)="loadSelectedFilter($event.value)" [placeholder]="'buttonLoadFilter' | translate" 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>{{ 'filterByType' | translate }}</mat-label>
<mat-select [(value)]="selectedTreeFilter" (selectionChange)="filterTree(searchTerm, $event.value)" disabled>
<mat-option [value]=""> {{ 'all' | translate }} </mat-option>
<mat-option value="classrooms-group">{{ 'classroomsGroup' | translate }}</mat-option>
<mat-option value="classroom">{{ 'classrooms' | translate }}</mat-option>
<mat-option value="group">{{ 'computerGroups' | translate }}</mat-option>
</mat-select>
</mat-form-field> -->
</div>
</div>
<!-- Tree -->
<!-- Tree -->
<div class="tree-container" joyrideStep="treePanelStep" text="{{ 'treePanelStepText' | translate }}">
<div class="tree-header">
<h3 class="tree-title">
@ -150,14 +81,69 @@
{{ 'organizationalStructure' | translate }}
</h3>
<div class="tree-actions">
<button mat-icon-button (click)="expandAll()" matTooltip="{{ 'expandAll' | translate }}">
<mat-icon>unfold_more</mat-icon>
<button mat-icon-button (click)="toggleSearchFilters()" matTooltip="{{ 'search' | translate }}" matTooltipShowDelay="1000">
<mat-icon>search</mat-icon>
</button>
<button mat-icon-button (click)="collapseAll()" matTooltip="{{ 'collapseAll' | translate }}">
<mat-icon>unfold_less</mat-icon>
<button mat-icon-button (click)="toggleExpandAll()" matTooltip="{{ (isTreeExpanded ? 'collapseAll' : 'expandAll') | translate }}">
<mat-icon>{{ isTreeExpanded ? 'unfold_less' : 'unfold_more' }}</mat-icon>
</button>
</div>
</div>
<!-- Filters -->
@if (showSearchFilters) {
<div class="filters-panel" joyrideStep="filtersPanelStep" text="{{ 'filtersPanelStepText' | translate }}">
<div class="filters-container">
<mat-form-field class="filter-form-field" appearance="outline">
<mat-label>{{ 'searchTree' | translate }}</mat-label>
<input matInput #treeSearchInput (input)="onTreeFilterInput($event)"
[placeholder]="'placeholderOrgUnitTypes' | translate" />
@if (treeSearchInput.value) {
<button mat-icon-button matSuffix [attr.aria-label]="'clearTreeSearch' | translate"
(click)="clearTreeSearch(treeSearchInput)">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field class="filter-form-field" appearance="outline">
<mat-label>{{ 'searchClient' | translate }}</mat-label>
<input matInput #clientSearchInput (input)="onClientFilterInput($event)"
[placeholder]="'searchNameIpStatusMac' | translate" />
@if (clientSearchInput.value) {
<button mat-icon-button matSuffix [attr.aria-label]="'clearClientSearch' | translate"
(click)="clearClientSearch(clientSearchInput)">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field class="form-field search-select" appearance="outline">
<mat-select placeholder="{{ 'searchState' | translate }}" #clientSearchStatusInput
(selectionChange)="onClientFilterStatusInput($event.value)">
@for (option of status; track option) {
<mat-option [value]="option.value">
{{ option.name }}
</mat-option>
}
</mat-select>
@if (clientSearchStatusInput.value) {
<button mat-icon-button matSuffix [attr.aria-label]="'clearTreeSearch' | translate"
(click)="clearStatusFilter($event, clientSearchStatusInput)">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
<div class="direct-clients-toggle">
<mat-slide-toggle
[(ngModel)]="showOnlyDirectClients"
(change)="onDirectClientsToggleChange()"
matTooltip="{{ 'directClientsToggleTooltip' | translate }}"
matTooltipShowDelay="1000">
{{ 'directClientsToggle' | translate }}
</mat-slide-toggle>
</div>
</div>
</div>
}
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl" class="modern-tree">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id, 'tree-node': true}"
*matTreeNodeDef="let node; when: hasChild" matTreeNodePadding (click)="onNodeClick($event, node)">
@ -420,7 +406,7 @@
<span class="client-ip">{{ client.ip }}</span>
<span class="client-mac">{{ client.mac }}</span>
@if (client.netSpeed) {
<span class="badge-net-speed" [matTooltip]="client.netSpeed + ' Mbps'">
<span class="badge-net-speed speed-{{client.netSpeed}} " [matTooltip]="client.netSpeed + ' Mbps'">
<mat-icon class="net-speed-icon">speed</mat-icon> {{ client.netSpeed }} Mbps
</span>
}
@ -546,7 +532,7 @@
<span class="client-ip">{{ client.ip }}</span>
<span class="client-mac">{{ client.mac }}</span>
@if (client.netSpeed) {
<span class="badge-net-speed list-view-badge" [matTooltip]="client.netSpeed + ' Mbps'">
<span class="badge-net-speed speed-{{client.netSpeed}} list-view-badge" [matTooltip]="client.netSpeed + ' Mbps'">
<mat-icon class="net-speed-icon">speed</mat-icon> {{ client.netSpeed }} Mbps
</span>
}

View File

@ -30,7 +30,7 @@ import { MatMenuTrigger } from '@angular/material/menu';
import { ClientDetailsComponent } from './shared/client-details/client-details.component';
import { PartitionTypeOrganizatorComponent } from './shared/partition-type-organizator/partition-type-organizator.component';
import { ClientTaskLogsComponent } from '../task-logs/client-task-logs/client-task-logs.component';
import {ChangeParentComponent} from "./shared/change-parent/change-parent.component";
import { ChangeParentComponent } from "./shared/change-parent/change-parent.component";
import { AuthService } from '@services/auth.service';
import { ClientPendingTasksComponent } from '../task-logs/client-pending-tasks/client-pending-tasks.component';
import { ClientLogsModalComponent } from './shared/client-logs-modal/client-logs-modal.component';
@ -88,6 +88,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
private clientFilterSubject = new Subject<string>();
loading = false;
showOnlyDirectClients: boolean = false;
showSearchFilters: boolean = false;
// Nuevas propiedades para funcionalidades mejoradas
selectedClient: any = null;
@ -102,11 +103,11 @@ export class GroupsComponent implements OnInit, OnDestroy {
online: number;
busy: number;
} = {
total: 0,
off: 0,
online: 0,
busy: 0
};
total: 0,
off: 0,
online: 0,
busy: 0
};
// Tipos de firmware disponibles
firmwareTypes: string[] = [];
@ -163,6 +164,15 @@ export class GroupsComponent implements OnInit, OnDestroy {
if (!this.pageSizeOptions.includes(this.itemsPerPage)) {
this.pageSizeOptions = [...this.pageSizeOptions, this.itemsPerPage].sort((a, b) => a - b);
}
// Load search filters visibility state from localStorage, default false
const savedShowSearch = localStorage.getItem('showSearchFilters');
this.showSearchFilters = savedShowSearch ? JSON.parse(savedShowSearch) : false;
}
toggleSearchFilters(): void {
this.showSearchFilters = !this.showSearchFilters;
localStorage.setItem('showSearchFilters', JSON.stringify(this.showSearchFilters));
}
@ -485,7 +495,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selection.select(client);
}
});
if (this.isCheckingNodeStatus) {
members.forEach((c: Client) => c.uuid && this.syncingClientIds.add(c.uuid));
}
@ -533,7 +543,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
this.loading = false;
});
}
@ -886,27 +896,27 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.add(c.uuid));
this.checkStatusSubscription = this.http.post<{ results: Array<{ uuid: string; ip: string; status: string; success: boolean }> }>(
`${this.baseUrl}/organizational-units/${node.uuid}/agent/status`,
{}
).subscribe({
next: (response) => {
this.isCheckingNodeStatus = false;
const statusMap = new Map(response.results.map(r => [r.uuid, r.status]));
response.results.forEach(r => this.syncingClientIds.delete(r.uuid));
this.selectedClients.data = this.selectedClients.data.map(client => {
const newStatus = statusMap.get(client.uuid);
return newStatus ? { ...client, status: newStatus } : client;
});
this.arrayClients = this.selectedClients.data;
this.calculateLocalStats();
this.toastr.success(this.translate.instant('classroomStatusUpdated'));
},
error: () => {
this.isCheckingNodeStatus = false;
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.delete(c.uuid));
this.toastr.error(this.translate.instant('errorCheckingStatus'));
},
});
`${this.baseUrl}/organizational-units/${node.uuid}/agent/status`,
{}
).subscribe({
next: (response) => {
this.isCheckingNodeStatus = false;
const statusMap = new Map(response.results.map(r => [r.uuid, r.status]));
response.results.forEach(r => this.syncingClientIds.delete(r.uuid));
this.selectedClients.data = this.selectedClients.data.map(client => {
const newStatus = statusMap.get(client.uuid);
return newStatus ? { ...client, status: newStatus } : client;
});
this.arrayClients = this.selectedClients.data;
this.calculateLocalStats();
this.toastr.success(this.translate.instant('classroomStatusUpdated'));
},
error: () => {
this.isCheckingNodeStatus = false;
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.delete(c.uuid));
this.toastr.error(this.translate.instant('errorCheckingStatus'));
},
});
}
@ -988,11 +998,11 @@ export class GroupsComponent implements OnInit, OnDestroy {
const selectedClientNames = clientData.map(client => client.name);
if (
this.selectedNode &&
this.selectedNode &&
clientData.length === this.selectedClients.data.length &&
selectedClientNames.every(name => this.selectedClients.data.some(c => c.name === name))
) {
return this.selectedNode;
return this.selectedNode;
} else if (clientData.length === 1) {
return clientData[0];
} else if (clientData.length > 1) {
@ -1017,7 +1027,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
});
}
changeParent(event: MouseEvent, ): void {
changeParent(event: MouseEvent,): void {
event.stopPropagation();
const dialogRef = this.dialog.open(ChangeParentComponent, {
@ -1128,7 +1138,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=1&itemsPerPage=10000`).subscribe({
next: (response) => {
const allClients = response['hydra:member'] || [];
if (allClients.length === 0) {
this.toastr.warning(this.translate.instant('thisOrganizationalUnitDoesNotHave'));
return;
@ -1252,7 +1262,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
getStatusCount(status: string): number {
switch(status) {
switch (status) {
case 'off':
return this.totalStats.off;
case 'online':
@ -1266,12 +1276,15 @@ export class GroupsComponent implements OnInit, OnDestroy {
// Métodos para el árbol mejorado
expandAll(): void {
this.treeControl.expandAll();
}
isTreeExpanded: boolean = false;
collapseAll(): void {
this.treeControl.collapseAll();
toggleExpandAll(): void {
if (this.isTreeExpanded) {
this.treeControl.collapseAll();
} else {
this.treeControl.expandAll();
}
this.isTreeExpanded = !this.isTreeExpanded;
}
getNodeTypeTooltip(nodeType: string): string {
@ -1358,7 +1371,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
deleteMultipleClients(event: MouseEvent): void {
event.stopPropagation();
if (this.selection.selected.length === 0) {
this.toastr.warning(this.translate.instant('thereAreNoClientsSelectedFor'));
return;
@ -1366,10 +1379,10 @@ export class GroupsComponent implements OnInit, OnDestroy {
const selectedClients = this.selection.selected;
const clientNames = selectedClients.map(client => client.name).join(', ');
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '500px',
data: {
data: {
name: `${selectedClients.length} cliente${selectedClients.length > 1 ? 's' : ''}`,
message: `¿Está seguro de que desea eliminar los siguientes clientes?\n\n${clientNames}`,
isMultiple: true
@ -1391,7 +1404,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
next: (response) => {
this.loading = false;
this.showDeletionResults(response, selectedClients);
this.refreshData(this.selectedNode?.id);
this.selection.clear();
},

View File

@ -28,9 +28,13 @@
<mat-form-field appearance="fill" class="search-boolean" joyrideStep="selectClassStep"
text="{{ 'selectClassDescription' | translate }}">
<mat-label>{{ 'labelSelectClass' | translate }}</mat-label>
<mat-select (selectionChange)="loadChildUnits($event)">
@for (unit of units; track unit.id) {
<mat-option [value]="unit">{{ unit.name }}</mat-option>
<mat-select (selectionChange)="loadChildUnits($event)" (opened)="unitSearchText = ''">
<div class="dropdown-search-wrapper">
<input type="text" class="dropdown-search-input" [(ngModel)]="unitSearchText" [ngModelOptions]="{standalone: true}" (keydown)="$event.stopPropagation()"
placeholder="{{ 'labelSearch' | translate }}">
</div>
@for (unit of getFilteredUnits(); track unit.id) {
<mat-option [value]="unit">{{ unit.displayName }}</mat-option>
}
</mat-select>
</mat-form-field>

View File

@ -32,6 +32,15 @@ export class PxeBootFilesComponent implements OnInit {
isFromDashboard: boolean = false;
ogLiveOptions: any[] = [];
globalOgLive: string | null = null;
unitSearchText: string = '';
getFilteredUnits(): any[] {
if (!this.unitSearchText) {
return this.units;
}
const searchText = this.unitSearchText.toLowerCase();
return this.units.filter(unit => unit.displayName.toLowerCase().includes(searchText));
}
length: number = 0;
itemsPerPage: number = 10;
page: number = 0;
@ -97,7 +106,11 @@ export class PxeBootFilesComponent implements OnInit {
loadUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?type=classroom&page=1&itemsPerPage=50`).subscribe(
response => {
this.units = response['hydra:member'];
this.units = response['hydra:member'].map((unit: any) => ({
...unit,
displayName: unit.parent && unit.parent.name ? `${unit.parent.name} / ${unit.name}` : unit.name
}));
this.units.sort((a, b) => a.displayName.localeCompare(b.displayName));
},
error => console.error(this.translate.instant('errorFetchingOrganizationalUnits1'), error)
);
@ -125,7 +138,10 @@ export class PxeBootFilesComponent implements OnInit {
if (!this.selectedUnit) return;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${this.selectedUnit.id}`).subscribe(
response => {
this.dataSource.data = response['hydra:member'];
this.dataSource.data = response['hydra:member'].map((client: any) => ({
...client,
ogLive: client.pxeTemplate ? client.pxeTemplate['@id'] : null
}));
},
error => console.error(this.translate.instant('errorFetchingChildUnits'), error)
);

View File

@ -39,4 +39,3 @@ mat-dialog-actions {
display: block;
margin-bottom: 10px;
}

View File

@ -5,8 +5,14 @@
<mat-dialog-content>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectClassroomPrompt' | translate }}</mat-label>
<mat-select [formControl]="unitControl" (selectionChange)="loadChildUnits($event)">
<mat-option *ngFor="let unit of units" [value]="unit">{{ unit.name }}</mat-option>
<mat-select [formControl]="unitControl" (selectionChange)="loadChildUnits($event)" (opened)="unitSearchText = ''">
<div class="dropdown-search-wrapper">
<input type="text" class="dropdown-search-input" [(ngModel)]="unitSearchText" [ngModelOptions]="{standalone: true}" (keydown)="$event.stopPropagation()"
placeholder="{{ 'labelSearch' | translate }}">
</div>
@for (unit of getFilteredUnits(); track unit.id) {
<mat-option [value]="unit">{{ unit.displayName }}</mat-option>
}
</mat-select>
</mat-form-field>
@ -17,16 +23,20 @@
{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}
</button>
<div *ngIf="clients.length > 0" class="clients-grid">
<mat-checkbox *ngFor="let client of clients"
(change)="toggleClientSelection(client.uuid)"
[checked]="selectedClients.includes(client.uuid)">
{{ client.name }}
</mat-checkbox>
</div>
<div *ngIf="clients.length === 0">
<p>{{ 'noClientsMessage' | translate }}</p>
</div>
@if (clients.length > 0) {
<div class="clients-grid">
@for (client of clients; track client.uuid) {
<mat-checkbox (change)="toggleClientSelection(client.uuid)"
[checked]="selectedClients.includes(client.uuid)">
{{ client.name }}
</mat-checkbox>
}
</div>
} @else {
<div>
<p>{{ 'noClientsMessage' | translate }}</p>
</div>
}
</div>
</mat-dialog-content>

View File

@ -23,6 +23,15 @@ export class AddClientsToSubnetComponent implements OnInit {
unitControl = new FormControl();
childUnitControl = new FormControl();
allSelected: boolean = false;
unitSearchText: string = '';
getFilteredUnits(): any[] {
if (!this.unitSearchText) {
return this.units;
}
const searchText = this.unitSearchText.toLowerCase();
return this.units.filter(unit => unit.displayName.toLowerCase().includes(searchText));
}
constructor(
private http: HttpClient,
@ -43,7 +52,11 @@ export class AddClientsToSubnetComponent implements OnInit {
loadUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=500`).subscribe(
response => {
this.units = response['hydra:member'];
this.units = response['hydra:member'].map((unit: any) => ({
...unit,
displayName: unit.parent && unit.parent.name ? `${unit.parent.name} / ${unit.name}` : unit.name
}));
this.units.sort((a, b) => a.displayName.localeCompare(b.displayName));
this.loading = false;
},
error => console.error(this.translate.instant('errorFetchingOrganizationalUnits1'), error)

View File

@ -197,3 +197,20 @@ mat-toolbar button[mat-icon-button] {
.modal-overlay-blur.error {
background: rgba(220, 53, 69, 0.8);
}
/* Dropdown search elements for material select */
.dropdown-search-wrapper {
padding: 10px;
position: sticky;
top: 0;
background-color: white;
z-index: 100;
}
.dropdown-search-input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}