refs 2335. Groups new UX
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details

develop
Manuel Aranda Rosales 2025-06-26 15:55:19 +02:00
parent d526bb851a
commit 90d969ccd3
8 changed files with 1809 additions and 139 deletions

View File

@ -31,7 +31,10 @@
</div>
<div class="table-header-container">
<h2 class="title" i18n="@@adminImagesTitle">Discos/Particiones</h2>
<mat-chip> {{ clientData.firmwareType }}</mat-chip>
<mat-chip *ngIf="clientData.firmwareType" class="firmware-chip">
<mat-icon>memory</mat-icon>
{{ clientData.firmwareType }}
</mat-chip>
</div>
<div class="disk-container">

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,8 @@
<app-modal-overlay
[isVisible]="loading"
message="Cargando...">
</app-modal-overlay>
<div class="groups-container">
<!-- HEADER -->
<div class="header-container">
@ -96,8 +101,6 @@
</button>
</mat-form-field>
<mat-divider class="tree-mat-divider" style="padding-top: 10px;"></mat-divider>
<!-- Funcionalidad actualmente deshabilitada-->
<!-- <mat-form-field appearance="outline">
<mat-select (selectionChange)="loadSelectedFilter($event.value)" placeholder="Cargar filtro" disabled>
@ -120,48 +123,107 @@
<!-- Tree -->
<div class="tree-container" joyrideStep="treePanelStep" text="{{ 'treePanelStepText' | translate }}">
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}"
<div class="tree-header">
<h3 class="tree-title">
<mat-icon>account_tree</mat-icon>
{{ 'organizationalStructure' | translate }}
</h3>
<div class="tree-actions">
<button mat-icon-button (click)="expandAll()" matTooltip="{{ 'expandAll' | translate }}">
<mat-icon>unfold_more</mat-icon>
</button>
<button mat-icon-button (click)="collapseAll()" matTooltip="{{ 'collapseAll' | translate }}">
<mat-icon>unfold_less</mat-icon>
</button>
</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)">
<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>
<mat-icon class="node-icon {{ node.type }}">
{{
node.type === 'organizational-unit' ? 'apartment'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'lan'
: node.type === 'client' ? 'computer'
: 'group'
}}
</mat-icon>
<span>{{ node.name }}</span>
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)">
<mat-icon>more_vert</mat-icon>
</button>
<div class="node-content">
<button mat-icon-button matTreeNodeToggle [disabled]="!node.expandable"
[ngClass]="{'disabled-toggle': !node.expandable}" class="expand-button">
<mat-icon class="expand-icon">{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}</mat-icon>
</button>
<div class="node-info">
<div class="node-main">
<mat-icon class="node-icon {{ node.type }}" [matTooltip]="getNodeTypeTooltip(node.type)">
{{
node.type === 'organizational-unit' ? 'business'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'dns'
: node.type === 'client' ? 'computer'
: 'folder'
}}
</mat-icon>
<span class="node-name" [matTooltip]="node.name">{{ node.name }}</span>
</div>
<div class="node-details">
<ng-container *ngIf="node.type === 'client'">
<span class="node-ip">{{ node.ip }}</span>
</ng-container>
<ng-container *ngIf="node.children && node.children.length > 0">
<span class="node-count">{{ node.children.length }} {{ getNodeCountLabel(node.children.length) }}</span>
</ng-container>
</div>
</div>
<div class="node-actions">
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"
class="menu-button" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</mat-tree-node>
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}"
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id, 'tree-node': true}"
*matTreeNodeDef="let node; when: isLeafNode" matTreeNodePadding (click)="onNodeClick($event, node)">
<button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle"></button>
<mat-icon style="color: green;">
{{
node.type === 'organizational-unit' ? 'apartment'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'lan'
: node.type === 'client' ? 'computer'
: 'group'
}}
</mat-icon>
<span>{{ node.name }}</span>
<ng-container *ngIf="node.type === 'client'">
<span> - IP: {{ node.ip }}</span>
</ng-container>
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)">
<mat-icon>more_vert</mat-icon>
</button>
<div class="node-content">
<button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle expand-button">
<mat-icon class="expand-icon">chevron_right</mat-icon>
</button>
<div class="node-info">
<div class="node-main">
<mat-icon class="node-icon {{ node.type }}" [ngClass]="{'client-status': node.type === 'client'}"
[matTooltip]="getNodeTypeTooltip(node.type)">
{{
node.type === 'organizational-unit' ? 'business'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'dns'
: node.type === 'client' ? 'computer'
: 'folder'
}}
</mat-icon>
<span class="node-name" [matTooltip]="node.name">{{ node.name }}</span>
</div>
<div class="node-details">
<ng-container *ngIf="node.type === 'client'">
<span class="node-ip">{{ node.ip }}</span>
<span class="node-mac">{{ node.mac }}</span>
<span class="node-status" [ngClass]="'status-' + (node.status || 'off')">
{{ getStatusLabel(node.status) }}
</span>
</ng-container>
</div>
</div>
<div class="node-actions">
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"
class="menu-button" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</mat-tree-node>
</mat-tree>
</div>
@ -205,6 +267,10 @@
<mat-icon>storage</mat-icon>
<span>{{ 'partitions' | translate }}</span>
</button>
<button mat-menu-item (click)="openOUPendingTasks($event, selectedNode)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<app-execute-command [clientData]="selectedNode?.clients || []" [buttonType]="'menu-item'"
[buttonText]="'ejecutarComandos' | translate" [icon]="'terminal'"
[disabled]="!((selectedNode?.clients ?? []).length > 0)" [runScriptContext]="selectedNode?.name || ''"
@ -254,6 +320,45 @@
text="{{ 'clientsViewStepText' | translate }}">
<div *ngIf="hasClients; else noClientsTemplate">
<div class="stats-container" *ngIf="currentView === 'list'">
<div class="stat-card">
<div class="stat-icon">
<mat-icon>computer</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ totalStats.total }}</div>
<div class="stat-label">{{ 'totalClients' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon offline">
<mat-icon>wifi_off</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('off') }}</div>
<div class="stat-label">{{ 'offline' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon online">
<mat-icon>wifi</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('og-live') + getStatusCount('linux') + getStatusCount('windows') }}</div>
<div class="stat-label">{{ 'online' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon busy">
<mat-icon>hourglass_empty</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('busy') }}</div>
<div class="stat-label">{{ 'busy' | translate }}</div>
</div>
</div>
</div>
<!-- Cards view -->
<div *ngIf="currentView === 'card'">
<section class="cards-view">
@ -262,7 +367,7 @@
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
<div class="clients-grid">
<div *ngFor="let client of arrayClients" class="client-item">
<div *ngFor="let client of arrayClients" class="client-item" [ngClass]="'status-' + client.status">
<div class="client-card">
<mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(client)"
[checked]="selection.isSelected(client)">
@ -271,9 +376,9 @@
alt="Client Icon" class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name }}</span>
<span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
<span class="client-mac">{{ client.mac }}</span>
<div class="action-icons">
<app-execute-command [clientState]="client.status" [clientData]="[client]"
@ -309,6 +414,10 @@
<mat-icon>list_alt</mat-icon>
<span>{{ 'procedimientosCliente' | translate }}</span>
</button>
<button mat-menu-item (click)="openClientPendingTasks($event, client)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
@ -327,10 +436,24 @@
</div>
</div>
<!-- List view -->
<!-- List view mejorada -->
<div *ngIf="currentView === 'list'" class="list-view">
<div class="table-header">
<div class="table-info">
<span>{{ 'showingResults' | translate: { from: getPaginationFrom(), to: getPaginationTo(), total: getPaginationTotal() } }}</span>
</div>
<div class="table-actions">
<button mat-icon-button (click)="refreshClientData()" matTooltip="{{ 'refresh' | translate }}">
<mat-icon>refresh</mat-icon>
</button>
<button mat-icon-button (click)="exportToCSV()" matTooltip="{{ 'exportCSV' | translate }}">
<mat-icon>download</mat-icon>
</button>
</div>
</div>
<section class="clients-table" tabindex="0">
<table mat-table matSort [dataSource]="selectedClients">
<table mat-table [dataSource]="selectedClients" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
@ -345,7 +468,14 @@
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'status' | translate }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'status' | translate }}</span>
<button mat-icon-button (click)="sortColumn('status')" class="sort-button">
<mat-icon>{{ getSortIcon('status') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<div class="client-status-container">
@ -359,72 +489,101 @@
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'name' | translate }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'name' | translate }}</span>
<button mat-icon-button (click)="sortColumn('name')" class="sort-button">
<mat-icon>{{ getSortIcon('name') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<p>{{ client.name }}</p>
<div class="client-cell">
<span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="ip">
<th mat-header-cell *matHeaderCellDef mat-sort-header>IP </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>IP</span>
<button mat-icon-button (click)="sortColumn('ip')" class="sort-button">
<mat-icon>{{ getSortIcon('ip') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<div style="display: flex; flex-direction: column;">
<span>{{ client.ip }}</span>
<span style="font-size: 0.75rem; color: gray;">{{ client.mac }}</span>
<div class="client-cell">
<span class="client-ip">{{ client.ip }}</span>
<span class="client-mac">{{ client.mac }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="firmwareType">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'firmwareType' | translate }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'firmwareType' | translate }}</span>
<button mat-icon-button (click)="sortColumn('firmwareType')" class="sort-button">
<mat-icon>{{ getSortIcon('firmwareType') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client">
<mat-chip *ngIf="client.firmwareType">
<mat-chip *ngIf="client.firmwareType" class="firmware-chip">
{{ client.firmwareType }}
</mat-chip>
</td>
</ng-container>
<ng-container matColumnDef="oglive">
<th mat-header-cell *matHeaderCellDef mat-sort-header> OG Live </th>
<th mat-header-cell *matHeaderCellDef> OG Live </th>
<td mat-cell *matCellDef="let client">
<div style="display: flex; flex-direction: column;">
<span>{{ client.ogLive?.kernel }} </span>
<span style="font-size: 0.75rem; color: gray;"> {{ client.ogLive?.date | date }}</span>
<div class="oglive-cell">
<span class="oglive-kernel">{{ client.ogLive?.kernel }}</span>
<span class="oglive-date">{{ client.ogLive?.date | date }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="maintenace">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'maintenance' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'maintenance' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.maintenance }} </td>
</ng-container>
<ng-container matColumnDef="subnet">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'subnet' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'subnet' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.subnet }} </td>
</ng-container>
<ng-container matColumnDef="pxeTemplate">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'pxeTemplate' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.pxeTemplate?.name }} </td>
<th mat-header-cell *matHeaderCellDef> {{ 'pxeTemplate' | translate }} </th>
<td mat-cell *matCellDef="let client" class="truncate-cell-medium" [matTooltip]="client.pxeTemplate?.name"> {{ client.pxeTemplate?.name }} </td>
</ng-container>
<ng-container matColumnDef="parentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'parent' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.parentName }} </td>
<th mat-header-cell *matHeaderCellDef> {{ 'parent' | translate }} </th>
<td mat-cell *matCellDef="let client" class="truncate-cell-medium" [matTooltip]="client.parentName"> {{ client.parentName }} </td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'actions' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'actions' | translate }} </th>
<td mat-cell *matCellDef="let client">
<button
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
mat-icon-button [matMenuTriggerFor]="clientMenu" color="primary">
<mat-icon>more_vert</mat-icon>
</button>
<app-execute-command [clientState]="client.status" [clientData]="[client]" [buttonType]="'icon'"
[icon]="'terminal'"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
[runScriptContext]="getRunScriptContext([client])">
</app-execute-command>
<div class="action-buttons">
<button
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
mat-icon-button [matMenuTriggerFor]="clientMenu" color="primary" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
<app-execute-command [clientState]="client.status" [clientData]="[client]" [buttonType]="'icon'"
[icon]="'terminal'"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
[runScriptContext]="getRunScriptContext([client])" matTooltip="{{ 'executeCommand' | translate }}">
</app-execute-command>
<button mat-icon-button color="primary" (click)="onShowClientDetail($event, client)" matTooltip="{{ 'viewDetails' | translate }}">
<mat-icon>visibility</mat-icon>
</button>
</div>
<mat-menu #clientMenu="matMenu">
<button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>edit</mat-icon>
@ -442,6 +601,10 @@
<mat-icon>list_alt</mat-icon>
<span>{{ 'procedimientosCliente' | translate }}</span>
</button>
<button mat-menu-item (click)="openClientPendingTasks($event, client)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
@ -449,9 +612,11 @@
</mat-menu>
</td>
</ng-container>
<tr mat-header-row style="background-color: #f3f3f3;"
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;" class="mat-row"
[ngClass]="'status-' + row.status"
[class.selected-row]="selectedClient?.uuid === row.uuid"
(click)="selectClient(row)"></tr>
</table>
</section>
<mat-paginator class="list-paginator" [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page"

View File

@ -27,6 +27,7 @@ import { TreeNode } from './model/model';
import { LoadingComponent } from '../../shared/loading/loading.component';
import { ExecuteCommandComponent } from '../commands/main-commands/execute-command/execute-command.component';
import { ConfigService } from '@services/config.service';
import { ModalOverlayComponent } from '../../shared/modal-overlay/modal-overlay.component';
describe('GroupsComponent', () => {
let component: GroupsComponent;
@ -39,7 +40,7 @@ describe('GroupsComponent', () => {
};
await TestBed.configureTestingModule({
declarations: [GroupsComponent, ExecuteCommandComponent, LoadingComponent],
declarations: [GroupsComponent, ExecuteCommandComponent, LoadingComponent, ModalOverlayComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),

View File

@ -15,7 +15,6 @@ import { ShowOrganizationalUnitComponent } from './shared/organizational-units/s
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';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { PageEvent } from '@angular/material/paginator';
import { CreateMultipleClientComponent } from "./shared/clients/create-multiple-client/create-multiple-client.component";
@ -31,6 +30,7 @@ import { PartitionTypeOrganizatorComponent } from './shared/partition-type-organ
import { ClientTaskLogsComponent } from '../task-logs/client-task-logs/client-task-logs.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';
enum NodeType {
OrganizationalUnit = 'organizational-unit',
@ -79,6 +79,29 @@ export class GroupsComponent implements OnInit, OnDestroy {
arrayClients: any[] = [];
filters: { [key: string]: string } = {};
private clientFilterSubject = new Subject<string>();
loading = false;
// Nuevas propiedades para funcionalidades mejoradas
selectedClient: any = null;
sortBy: string = 'name';
sortDirection: 'asc' | 'desc' = 'asc';
currentSortColumn: string = 'name';
// Estadísticas totales
totalStats: {
total: number;
off: number;
online: number;
busy: number;
} = {
total: 0,
off: 0,
online: 0,
busy: 0
};
// Tipos de firmware disponibles
firmwareTypes: string[] = [];
protected status = [
{ value: 'off', name: 'Apagado' },
@ -95,16 +118,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
displayedColumns: string[] = ['select', 'status', 'ip', 'firmwareType', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
private _sort!: MatSort;
@ViewChild(MatSort)
set matSort(ms: MatSort) {
this._sort = ms;
if (this.selectedClients) {
this.selectedClients.sort = this._sort;
}
}
private subscriptions: Subscription = new Subscription();
constructor(
@ -404,8 +417,14 @@ export class GroupsComponent implements OnInit, OnDestroy {
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = []): void {
const params = new HttpParams({ fromObject: this.filters });
// Agregar parámetros de ordenamiento al backend
let backendParams = { ...this.filters };
if (this.sortBy) {
backendParams['order[' + this.sortBy + ']'] = this.sortDirection;
}
this.isLoadingClients = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params }).subscribe({
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
next: (response: any) => {
this.selectedClients.data = response['hydra:member'];
if (this.selectedNode) {
@ -423,6 +442,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selection.select(client);
}
});
// Calcular estadísticas después de cargar los clientes
this.calculateLocalStats();
},
error: () => {
this.isLoadingClients = false;
@ -438,25 +460,35 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
addOU(event: MouseEvent, parent: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation();
const dialogRef = this.dialog.open(ManageOrganizationalUnitComponent, {
data: { parent },
width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((newUnit) => {
if (newUnit) {
this.refreshData(newUnit.uuid);
}
this.loading = false;
});
}
addClient(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(ManageClientComponent, {
data: { organizationalUnit: targetNode },
width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
@ -469,17 +501,22 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.refreshData(parentNode.uuid);
}
}
this.loading = false;
});
}
addMultipleClients(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(CreateMultipleClientComponent, {
data: { organizationalUnit: targetNode },
width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
@ -494,29 +531,33 @@ export class GroupsComponent implements OnInit, OnDestroy {
console.error('No se encontró el nodo padre después de la creación masiva.');
}
}
this.loading = false;
});
}
onEditNode(event: MouseEvent, node: TreeNode | null): void {
event.stopPropagation();
this.loading = true;
const uuid = node ? this.extractUuid(node['@id']) : null;
if (!uuid) return;
const dialogRef = node?.type !== NodeType.Client
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px' });
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
this.refreshData(node?.id);
}
this.menuTriggers.forEach(trigger => trigger.closeMenu());
this.loading = false;
});
}
onDeleteClick(event: MouseEvent, entity: TreeNode | Client | null): void {
event.stopPropagation();
this.loading = true;
if (!entity) return;
const uuid = entity['@id'] ? this.extractUuid(entity['@id']) : null;
@ -533,6 +574,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
if (result === true) {
this.deleteEntityorClient(uuid, type);
}
this.loading = false;
});
}
@ -570,16 +612,18 @@ export class GroupsComponent implements OnInit, OnDestroy {
onEditClick(event: MouseEvent, type: string, uuid: string): void {
event.stopPropagation();
this.loading = true;
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
const dialogRef = type !== NodeType.Client
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px' });
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
this.refreshData(this.selectedNode?.id, selectedClientsBeforeEdit);
}
this.menuTriggers.forEach(trigger => trigger.closeMenu());
this.loading = false;
});
}
@ -592,6 +636,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.dialog.open(ClassroomViewDialogComponent, {
width: '90vw',
data: { clients: response['hydra:member'] },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
},
(error) => {
@ -603,35 +650,46 @@ export class GroupsComponent implements OnInit, OnDestroy {
executeCommand(command: Command, selectedNode: TreeNode | null): void {
this.loading = true;
if (!selectedNode) {
this.toastr.error('No hay un nodo seleccionado.');
return;
} else {
this.toastr.success(`Ejecutando comando: ${command.name} en ${selectedNode.name}`);
}
this.loading = false;
}
onShowClientDetail(event: MouseEvent, client: Client): void {
event.stopPropagation();
this.dialog.open(ClientDetailsComponent, {
this.loading = true;
const dialogRef = this.dialog.open(ClientDetailsComponent, {
width: '70vw',
height: '90vh',
data: { clientData: client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
onShowDetailsClick(event: MouseEvent, data: TreeNode | null): void {
event.stopPropagation();
this.loading = true;
if (data && data.type !== NodeType.Client) {
this.dialog.open(ShowOrganizationalUnitComponent, { data: { data }, width: '800px' });
this.dialog.open(ShowOrganizationalUnitComponent, { data: { data }, width: '800px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
} else {
if (data) {
this.router.navigate(['clients', this.extractUuid(data['@id'])], { state: { clientData: data } });
}
}
this.loading = false;
}
@ -873,6 +931,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
const dialogRef = this.dialog.open(ChangeParentComponent, {
data: { clients: this.selection.selected },
width: '700px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
@ -883,11 +944,269 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
openClientTaskLogs(event: MouseEvent, client: Client): void {
this.loading = true;
event.stopPropagation();
this.dialog.open(ClientTaskLogsComponent, {
const dialogRef = this.dialog.open(ClientTaskLogsComponent, {
width: '1200px',
data: { client }
data: { client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
openClientPendingTasks(event: MouseEvent, client: Client): void {
this.loading = true;
event.stopPropagation();
const dialogRef = this.dialog.open(ClientPendingTasksComponent, {
width: '1200px',
data: { client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
openOUPendingTasks(event: MouseEvent, node: any): void {
event.stopPropagation();
this.loading = true;
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('Esta unidad organizativa no tiene clientes');
return;
}
const ouClientData = {
name: node.name,
id: node.id,
uuid: node.uuid,
type: 'organizational-unit',
clients: allClients
};
const dialogRef = this.dialog.open(ClientPendingTasksComponent, {
width: '1200px',
data: { client: ouClientData, isOrganizationalUnit: true },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
},
error: (error) => {
console.error('Error al obtener los clientes de la unidad organizativa:', error);
this.toastr.error('Error al cargar los clientes de la unidad organizativa');
this.loading = false;
}
});
}
// Métodos para paginación
getPaginationFrom(): number {
return (this.page * this.itemsPerPage) + 1;
}
getPaginationTo(): number {
return Math.min((this.page + 1) * this.itemsPerPage, this.length);
}
getPaginationTotal(): number {
return this.length;
}
refreshClientData(): void {
this.fetchClientsForNode(this.selectedNode);
this.toastr.success('Datos actualizados', 'Éxito');
}
exportToCSV(): void {
const headers = ['Nombre', 'IP', 'MAC', 'Estado', 'Firmware', 'Subnet', 'Parent'];
const csvData = this.arrayClients.map(client => [
client.name,
client.ip || '',
client.mac || '',
client.status || '',
client.firmwareType || '',
client.subnet || '',
client.parentName || ''
]);
const csvContent = [headers, ...csvData]
.map(row => row.map(cell => `"${cell}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `clients_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.toastr.success('Archivo CSV exportado correctamente', 'Éxito');
}
private calculateLocalStats(): void {
const clients = this.arrayClients;
this.totalStats = {
total: clients.length,
off: clients.filter(client => client.status === 'off').length,
online: clients.filter(client => ['og-live', 'linux', 'windows', 'linux-session', 'windows-session'].includes(client.status)).length,
busy: clients.filter(client => client.status === 'busy').length
};
// Actualizar tipos de firmware disponibles
this.firmwareTypes = [...new Set(clients.map(client => client.firmwareType).filter(Boolean))];
}
// Métodos para funcionalidades mejoradas
selectClient(client: any): void {
this.selectedClient = client;
}
sortColumn(columnDef: string): void {
if (this.currentSortColumn === columnDef) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.currentSortColumn = columnDef;
this.sortDirection = 'asc';
}
this.sortBy = columnDef;
this.onSortChange();
}
getSortIcon(columnDef: string): string {
if (this.currentSortColumn !== columnDef) {
return 'unfold_more';
}
return this.sortDirection === 'asc' ? 'expand_less' : 'expand_more';
}
onSortChange(): void {
// Hacer nueva llamada al backend con el ordenamiento actualizado
this.fetchClientsForNode(this.selectedNode);
}
getStatusCount(status: string): number {
switch(status) {
case 'off':
return this.totalStats.off;
case 'online':
return this.totalStats.online;
case 'busy':
return this.totalStats.busy;
default:
return this.arrayClients.filter(client => client.status === status).length;
}
}
// Métodos para el árbol mejorado
expandAll(): void {
this.treeControl.expandAll();
}
collapseAll(): void {
this.treeControl.collapseAll();
}
getNodeTypeTooltip(nodeType: string): string {
switch (nodeType) {
case 'organizational-unit':
return 'Unidad Organizacional - Estructura principal de la organización';
case 'classrooms-group':
return 'Grupo de Aulas - Conjunto de aulas relacionadas';
case 'classroom':
return 'Aula - Espacio físico con equipos informáticos';
case 'clients-group':
return 'Grupo de Equipos - Conjunto de equipos informáticos';
case 'client':
return 'Equipo Informático - Computadora o dispositivo individual';
case 'group':
return 'Grupo - Agrupación lógica de elementos';
default:
return 'Elemento del árbol organizacional';
}
}
getNodeCountLabel(count: number): string {
if (count === 1) return 'elemento';
return 'elementos';
}
getStatusLabel(status: string): string {
const statusLabels: { [key: string]: string } = {
'off': 'Apagado',
'og-live': 'OG Live',
'linux': 'Linux',
'linux-session': 'Linux Session',
'windows': 'Windows',
'windows-session': 'Windows Session',
'busy': 'Ocupado',
'mac': 'Mac',
'disconnected': 'Desconectado',
'initializing': 'Inicializando'
};
return statusLabels[status] || status;
}
// Funciones para el dashboard de estadísticas
getTotalOrganizationalUnits(): number {
let total = 0;
const countOrganizationalUnits = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.type === 'organizational-unit') {
total += 1;
}
if (node.children) {
countOrganizationalUnits(node.children);
}
});
};
countOrganizationalUnits(this.originalTreeData);
return total;
}
getTotalClassrooms(): number {
let total = 0;
const countClassrooms = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.type === 'classroom') {
total += 1;
}
if (node.children) {
countClassrooms(node.children);
}
});
};
countClassrooms(this.originalTreeData);
return total;
}
// Función para actualizar estadísticas cuando cambian los datos
private updateDashboardStats(): void {
// Las estadísticas de equipos ya se calculan en calculateLocalStats()
// Solo necesitamos asegurar que se actualicen cuando cambian los datos
this.calculateLocalStats();
}
}

View File

@ -35,7 +35,7 @@ export class ClientViewComponent {
{ property: 'Fecha de creación', value: this.data.client.createdAt },
{ property: 'NTP', value: this.data.client.organizationalUnit?.networkSettings?.ntp || '' },
{ property: 'Modo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pMode || '' },
{ property: 'Tiempo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' },
...(this.data.client.organizationalUnit?.networkSettings?.p2pMode === 'seeder' ? [{ property: 'Tiempo p2p (minutos)', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' }] : []),
{ property: 'IP multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastIp || '' },
{ property: 'Modo multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastMode || '' },
{ property: 'Puerto multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastPort || '' },
@ -51,7 +51,7 @@ export class ClientViewComponent {
{ property: 'Router', value: this.data.client.organizationalUnit?.networkSettings?.router || '' },
{ property: 'NTP', value: this.data.client.organizationalUnit?.networkSettings?.ntp || '' },
{ property: 'Modo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pMode || '' },
{ property: 'Tiempo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' },
...(this.data.client.organizationalUnit?.networkSettings?.p2pMode === 'seeder' ? [{ property: 'Tiempo p2p (minutos)', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' }] : []),
{ property: 'IP multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastIp || '' },
{ property: 'Modo multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastMode || '' },
{ property: 'Puerto multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastPort || '' },

View File

@ -134,8 +134,8 @@
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pTimeLabel' | translate }}</mat-label>
<mat-form-field class="form-field" *ngIf="networkSettingsFormGroup.get('p2pMode')?.value === 'seeder'">
<mat-label>{{ 'p2pTimeLabel' | translate }} (minutos)</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
<mat-form-field class="form-field">

View File

@ -271,6 +271,13 @@ export class ManageOrganizationalUnitComponent implements OnInit {
onSubmit() {
if (this.generalFormGroup.valid && this.additionalInfoFormGroup.valid && this.networkSettingsFormGroup.valid) {
const parentValue = this.generalFormGroup.value.parent;
// Preparar networkSettings con lógica condicional para p2pTime
const networkSettings = { ...this.networkSettingsFormGroup.value };
if (networkSettings.p2pMode !== 'seeder') {
networkSettings.p2pTime = null;
}
const formData = {
name: this.generalFormGroup.value.name,
excludeParentChanges: this.generalFormGroup.value.excludeParentChanges,
@ -279,7 +286,7 @@ export class ManageOrganizationalUnitComponent implements OnInit {
comments: this.additionalInfoFormGroup.value.comments,
remoteCalendar: this.generalFormGroup.value.remoteCalendar,
type: this.generalFormGroup.value.type,
networkSettings: this.networkSettingsFormGroup.value,
networkSettings: networkSettings,
location: this.classroomInfoFormGroup.value.location,
projector: this.classroomInfoFormGroup.value.projector,
board: this.classroomInfoFormGroup.value.board,