From 5576757c7bfe786dcc242f77cd41461239a39372 Mon Sep 17 00:00:00 2001 From: jmbardallo Date: Tue, 20 Oct 2026 10:15:08 +0200 Subject: [PATCH] feat(commands): support offline client execution with auto-queueing and modernize components --- CHANGELOG.md | 14 +++ .../boot-so-partition.component.css | 4 + .../boot-so-partition.component.html | 114 +++++++++--------- .../boot-so-partition.component.ts | 9 +- .../execute-command.component.html | 45 +++---- .../execute-command.component.ts | 114 +++++++++++++----- .../create-image/create-image.component.ts | 3 +- .../deploy-image/deploy-image.component.ts | 3 +- .../partition-assistant.component.ts | 3 +- .../run-script-assistant.component.ts | 3 +- .../components/groups/groups.component.html | 13 +- .../queue-confirmation-modal.component.css | 11 ++ .../queue-confirmation-modal.component.html | 8 +- .../queue-confirmation-modal.component.ts | 26 +++- ogWebconsole/src/locale/cat.json | 2 + ogWebconsole/src/locale/en.json | 2 + ogWebconsole/src/locale/es.json | 2 + 17 files changed, 255 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de0f83d..5ca2e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.15.0] - 2026-10-20 + +### Added +- **Ejecución y Encolado Automático en Clientes Offline (`execute-command`, `boot-so-partition`, `queue-confirmation-modal`)**: + - Permitida la ejecución de comandos y scripts (incluyendo inicio de sesión en partición / arranque de SO) sobre equipos apagados o grupos con estados mixtos. + - El modal de confirmación detecta automáticamente equipos apagados o estados no ejecutables de inmediato, pre-seleccionando la opción de encolado y mostrando avisos visuales informativos (disponible en ES, EN y CA). + - Unificada y estandarizada la matriz de estados de todos los comandos en la consola de comandos. + +### Fixed +- **Selección de Clientes en Unidades Organizativas (`groups.component.html`)**: Solucionado el problema al invocar comandos o tareas desde el menú contextual de un Aula / OU donde solo se seleccionaban los equipos mostrados en la página actual de la tabla en lugar de todos los clientes pertenecientes a la OU. + +### Changed +- **Refactorización Angular (`boot-so-partition`)**: Migradas directivas estructurales (`*ngIf`, `*ngFor`) a la sintaxis moderna de control de flujo (`@if`, `@for`) y extraídos los estilos inline a clases CSS reutilizables. + ## [1.14.4] - 2026-10-09 ### Fixed diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.css b/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.css index 9f58e44..e952c03 100644 --- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.css +++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.css @@ -99,6 +99,10 @@ box-shadow: 0px 0px 0px rgba(0,0,0,0.2); } +.section-divider { + margin-top: 20px; +} + @media (max-width: 600px) { .form-field { width: 100%; diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.html b/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.html index c6bc6b2..e46d9b3 100644 --- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.html +++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.html @@ -1,66 +1,70 @@

{{ 'selectPartitionToBootOs' | translate }}

- + @if (loading) { + + } @else { +
+ + + {{ 'labelClients' | translate }} + + {{ 'clientListBootOs' | translate }}desktop_windows + + -
- - - {{ 'labelClients' | translate }} - - {{ 'clientListBootOs' | translate }}desktop_windows - - - -
- -
- -
-
-
- - Client Icon - -
- {{ client.name | slice:0:20 }} - {{ client.ip }} - {{ client.mac }} -
- - - - - {{ 'labelModel' | translate }} - -
+
+
-
- -
+
+ @for (client of data.clients; track client.uuid) { +
+
- + Client Icon -
- - -
+
+ {{ client.name | slice:0:20 }} + {{ client.ip }} + {{ client.mac }} +
+ + + + + {{ 'labelModel' | translate }} + +
+
+ } +
+ +
+
+ + + +
+ + +
+ }
diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.ts b/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.ts index 5e088d6..4cd852e 100644 --- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.ts +++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component.ts @@ -113,7 +113,7 @@ export class BootSoPartitionComponent implements OnInit { updateSelectedClients() { this.selectedClients = this.data.clients.filter( - (client: { selected: boolean; state: string }) => client.selected && client.state === "og-live" + (client: { selected: boolean }) => client.selected ); if (!this.selectedClients.includes(this.selectedModelClient)) { @@ -124,11 +124,10 @@ export class BootSoPartitionComponent implements OnInit { toggleSelectAll() { this.allSelected = !this.allSelected; - this.data.clients.forEach((client: { selected: boolean; status: string }) => { - if (client.status === "og-live") { - client.selected = this.allSelected; - } + this.data.clients.forEach((client: { selected: boolean }) => { + client.selected = this.allSelected; }); + this.updateSelectedClients(); } close() { diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html index 400dc52..ee9ddc7 100644 --- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html +++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html @@ -1,24 +1,27 @@ - - - - - - - +@switch (buttonType) { + @case ('icon') { + + } + @case ('text') { + + } + @case ('menu-item') { + + } +} - + @for (command of arrayCommands; track command.slug) { + + } \ No newline at end of file diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts index 2169886..f224291 100644 --- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts +++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts @@ -21,6 +21,8 @@ import { ExecuteTaskNowComponent } from './execute-task-now/execute-task-now.com export class ExecuteCommandComponent implements OnInit { public translate = inject(TranslateService); @Input() runScriptContext: any = null; + @Input() organizationalUnit: any = null; + @Input() showOnlyDirectClients: boolean = false; @Input() clientState: string = 'off'; @Input() clientData: any[] = []; @Input() buttonType: 'icon' | 'text' | 'menu-item' = 'icon'; @@ -29,12 +31,13 @@ export class ExecuteCommandComponent implements OnInit { @Input() disabled: boolean = false; baseUrl: string; loading: boolean = true; + isFetchingOuClients: boolean = false; arrayCommands: any[] = [ { translationKey: 'executeCommands.powerOn', slug: 'power-on', disabled: false }, { translationKey: 'executeCommands.powerOff', slug: 'power-off', disabled: false }, { translationKey: 'executeCommands.reboot', slug: 'reboot', disabled: false }, - { translationKey: 'executeCommands.login', slug: 'login', disabled: true }, + { translationKey: 'executeCommands.login', slug: 'login', disabled: false }, { translationKey: 'executeCommands.createImage', slug: 'create-image', disabled: false }, { translationKey: 'executeCommands.deployImage', slug: 'deploy-image', disabled: false }, { translationKey: 'executeCommands.deleteImageCache', slug: 'remove-cache-image', disabled: false }, @@ -113,37 +116,42 @@ export class ExecuteCommandComponent implements OnInit { states = [this.clientState]; } - const allOffOrDisconnected = states.every(state => state === 'off' || state === 'disconnected'); - const allSameState = states.every(state => state === states[0]); + const hasClients = this.clientData.length > 0 || !!this.clientState; const multipleClients = this.clientData.length > 1; + const hasOgLive = states.some(state => state === 'og-live'); + + // Acciones que admiten encolado o son independientes del estado del cliente + const queueableOrStateAgnostic = [ + 'power-on', + 'power-off', + 'reboot', + 'login', + 'deploy-image', + 'partition', + 'run-script', + 'execute-task', + 'delete-lease' + ]; this.arrayCommands = this.arrayCommands.map(command => { - if (allOffOrDisconnected) { - command.disabled = command.slug !== 'power-on' && command.slug !== 'create-image'; - } else if (allSameState) { - if (states[0] === 'off' || states[0] === 'disconnected') { - command.disabled = !['power-on', 'create-image'].includes(command.slug); - } else { - command.disabled = !['power-off', 'reboot', 'login', 'create-image', 'deploy-image', 'remove-cache-image', 'partition', 'run-script', 'software-inventory', 'hardware-inventory', 'refresh'].includes(command.slug); - } - } else { - if (command.slug === 'software-inventory') { - command.disabled = multipleClients; - } else if ( - ['power-on', 'power-off', 'reboot', 'login', 'deploy-image', 'partition', 'remove-cache-image', 'run-script', 'create-image'].includes(command.slug) - ) { - command.disabled = false; - } else { - command.disabled = true; - } - } - - if (command.slug === 'refresh') { - command.disabled = !states.every(state => state === 'og-live'); + if (!hasClients) { + command.disabled = true; + return command; } - if (command.slug === 'execute-task') { - command.disabled = this.clientData.length === 0; + // Restricciones lógicas estrictas + if (command.slug === 'create-image') { + command.disabled = multipleClients || !hasOgLive; + } else if (command.slug === 'hardware-inventory' || command.slug === 'software-inventory') { + command.disabled = multipleClients || !hasOgLive; + } else if (command.slug === 'remove-cache-image') { + command.disabled = !hasOgLive; + } else if (command.slug === 'refresh') { + command.disabled = !hasOgLive; + } else if (queueableOrStateAgnostic.includes(command.slug)) { + command.disabled = false; + } else { + command.disabled = false; } return command; @@ -151,6 +159,48 @@ export class ExecuteCommandComponent implements OnInit { } onCommandSelect(action: any): void { + if (this.organizationalUnit && this.organizationalUnit.id) { + this.fetchClientsForOuAndExecute(action); + return; + } + + this.executeAction(action); + } + + private fetchClientsForOuAndExecute(action: any): void { + if (this.isFetchingOuClients) { + return; + } + this.isFetchingOuClients = true; + + const unitId = this.organizationalUnit.id; + this.http.get(`${this.baseUrl}/clients?organizationalUnit.id=${unitId}&page=1&itemsPerPage=10000`).subscribe({ + next: (response) => { + this.isFetchingOuClients = false; + let allClients: any[] = response['hydra:member'] || []; + + if (this.showOnlyDirectClients && this.organizationalUnit['@id']) { + allClients = allClients.filter((client: any) => + client.organizationalUnit?.['@id'] === this.organizationalUnit['@id'] + ); + } + + if (allClients.length === 0) { + this.toastService.warning(this.translate.instant('thisOrganizationalUnitDoesNotHave') || 'Esta unidad organizativa no tiene clientes.'); + return; + } + + this.clientData = allClients; + this.executeAction(action); + }, + error: (error) => { + this.isFetchingOuClients = false; + this.toastService.error(this.translate.instant('errorLoadingClients1') || 'Error al cargar los clientes'); + } + }); + } + + private executeAction(action: any): void { if (action === 'partition') { this.openPartitionAssistant(); } @@ -217,7 +267,10 @@ export class ExecuteCommandComponent implements OnInit { } rebootClient(): void { - const dialogRef = this.dialog.open(QueueConfirmationModalComponent, { width: '400px' }); + const dialogRef = this.dialog.open(QueueConfirmationModalComponent, { + width: '400px', + data: { clients: this.clientData } + }); dialogRef.afterClosed().subscribe((queue: boolean | undefined) => { if (queue === undefined) return; @@ -356,7 +409,10 @@ export class ExecuteCommandComponent implements OnInit { } powerOffClient(): void { - const dialogRef = this.dialog.open(QueueConfirmationModalComponent, { width: '400px' }); + const dialogRef = this.dialog.open(QueueConfirmationModalComponent, { + width: '400px', + data: { clients: this.clientData } + }); dialogRef.afterClosed().subscribe((queue: boolean | undefined) => { if (queue === undefined) return; diff --git a/ogWebconsole/src/app/components/groups/components/client-main-view/create-image/create-image.component.ts b/ogWebconsole/src/app/components/groups/components/client-main-view/create-image/create-image.component.ts index a752bae..cc6e9f0 100644 --- a/ogWebconsole/src/app/components/groups/components/client-main-view/create-image/create-image.component.ts +++ b/ogWebconsole/src/app/components/groups/components/client-main-view/create-image/create-image.component.ts @@ -372,7 +372,8 @@ export class CreateClientImageComponent implements OnInit{ width: '400px', disableClose: true, hasBackdrop: true, - backdropClass: 'non-clickable-backdrop' + backdropClass: 'non-clickable-backdrop', + data: { clients: this.client ? [this.client] : [] } }); dialogRef.afterClosed().subscribe(result => { diff --git a/ogWebconsole/src/app/components/groups/components/client-main-view/deploy-image/deploy-image.component.ts b/ogWebconsole/src/app/components/groups/components/client-main-view/deploy-image/deploy-image.component.ts index d95d62c..3209916 100644 --- a/ogWebconsole/src/app/components/groups/components/client-main-view/deploy-image/deploy-image.component.ts +++ b/ogWebconsole/src/app/components/groups/components/client-main-view/deploy-image/deploy-image.component.ts @@ -481,7 +481,8 @@ export class DeployImageComponent implements OnInit { width: '400px', disableClose: true, hasBackdrop: true, - backdropClass: 'non-clickable-backdrop' + backdropClass: 'non-clickable-backdrop', + data: { clients: this.clientData } }); this.toastService.info(this.translate.instant('preparingDeploymentRequest')); diff --git a/ogWebconsole/src/app/components/groups/components/client-main-view/partition-assistant/partition-assistant.component.ts b/ogWebconsole/src/app/components/groups/components/client-main-view/partition-assistant/partition-assistant.component.ts index 13a868f..56d46f7 100644 --- a/ogWebconsole/src/app/components/groups/components/client-main-view/partition-assistant/partition-assistant.component.ts +++ b/ogWebconsole/src/app/components/groups/components/client-main-view/partition-assistant/partition-assistant.component.ts @@ -523,7 +523,8 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes width: '400px', disableClose: true, hasBackdrop: true, - backdropClass: 'non-clickable-backdrop' + backdropClass: 'non-clickable-backdrop', + data: { clients: this.clientData } }); dialogRef.afterClosed().subscribe(result => { diff --git a/ogWebconsole/src/app/components/groups/components/client-main-view/run-script-assistant/run-script-assistant.component.ts b/ogWebconsole/src/app/components/groups/components/client-main-view/run-script-assistant/run-script-assistant.component.ts index 3fb5e04..e60e4bd 100644 --- a/ogWebconsole/src/app/components/groups/components/client-main-view/run-script-assistant/run-script-assistant.component.ts +++ b/ogWebconsole/src/app/components/groups/components/client-main-view/run-script-assistant/run-script-assistant.component.ts @@ -195,7 +195,8 @@ export class RunScriptAssistantComponent implements OnInit{ const dialogRef = this.dialog.open(QueueConfirmationModalComponent, { width: '400px', disableClose: true, - hasBackdrop: true + hasBackdrop: true, + data: { clients: this.selectedClients } }); dialogRef.afterClosed().subscribe(result => { diff --git a/ogWebconsole/src/app/components/groups/groups.component.html b/ogWebconsole/src/app/components/groups/groups.component.html index 1d0b3ec..ad34efa 100644 --- a/ogWebconsole/src/app/components/groups/groups.component.html +++ b/ogWebconsole/src/app/components/groups/groups.component.html @@ -281,10 +281,15 @@ pending_actions {{ 'colaAcciones' | translate }} - +
diff --git a/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.css b/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.css index 835ba25..d5a5935 100644 --- a/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.css +++ b/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.css @@ -51,4 +51,15 @@ mat-dialog-content { mat-checkbox { margin-top: 15px; display: block; +} + +.queue-warning-info { + background-color: #fff3cd; + border-left: 4px solid #ffc107; + padding: 10px 14px; + border-radius: 4px; + color: #856404; + font-size: 14px; + margin-bottom: 16px; + line-height: 1.4; } \ No newline at end of file diff --git a/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.html b/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.html index aee715b..4632700 100644 --- a/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.html +++ b/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.html @@ -1,6 +1,12 @@

{{ 'confirmationTitle' | translate }}

-

{{ 'queueActionsForFailedPcs' | translate }}

+ @if (allOff) { +

{{ 'queueAllOffWarning' | translate }}

+ } @else if (hasMixedStates) { +

{{ 'queueMixedStatesWarning' | translate: { on: onCount, off: offCount } }}

+ } @else { +

{{ 'queueActionsForFailedPcs' | translate }}

+ } {{ 'queueActions' | translate }}
diff --git a/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.ts b/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.ts index 722873c..4884b0a 100644 --- a/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.ts +++ b/ogWebconsole/src/app/shared/queue-confirmation-modal/queue-confirmation-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject } from '@angular/core'; +import { Component, Inject, OnInit } from '@angular/core'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @Component({ @@ -6,14 +6,36 @@ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; templateUrl: './queue-confirmation-modal.component.html', styleUrl: './queue-confirmation-modal.component.css' }) -export class QueueConfirmationModalComponent { +export class QueueConfirmationModalComponent implements OnInit { shouldQueue: boolean = false; + allOff: boolean = false; + hasMixedStates: boolean = false; + onCount: number = 0; + offCount: number = 0; + totalCount: number = 0; constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any ) {} + ngOnInit(): void { + const clients: any[] = this.data?.clients || []; + if (clients.length > 0) { + this.totalCount = clients.length; + this.offCount = clients.filter(c => !c.status || c.status === 'off' || c.status === 'disconnected').length; + this.onCount = this.totalCount - this.offCount; + + if (this.offCount === this.totalCount) { + this.allOff = true; + this.shouldQueue = true; + } else if (this.offCount > 0) { + this.hasMixedStates = true; + this.shouldQueue = true; + } + } + } + onNoClick(): void { this.dialogRef.close(); } diff --git a/ogWebconsole/src/locale/cat.json b/ogWebconsole/src/locale/cat.json index 64b1edd..d030c2d 100644 --- a/ogWebconsole/src/locale/cat.json +++ b/ogWebconsole/src/locale/cat.json @@ -1169,6 +1169,8 @@ "pxeTemplateUpdatedSuccessfully": "Plantilla PXE actualitzada correctament", "queueActions": "Encuar accions", "queueActionsForFailedPcs": "Vols que s'encuin les accions per als PCs que no es pugui executar?", + "queueAllOffWarning": "Tots els equips seleccionats estan apagats o desconnectats. L'acció s'encolarà per executar-se quan arrenquin.", + "queueMixedStatesWarning": "Hi ha {{ on }} equips encesos i {{ off }} apagats o desconnectats. Es recomana encuar perquè els equips apagats executin l'acció en arrencar.", "RamUsage": "Ús de RAM", "readOnly": "Només Lectura", "realTimeDhcpLogs": "Logs en temps real del servidor DHCP", diff --git a/ogWebconsole/src/locale/en.json b/ogWebconsole/src/locale/en.json index dacacea..1f5a0ad 100644 --- a/ogWebconsole/src/locale/en.json +++ b/ogWebconsole/src/locale/en.json @@ -1169,6 +1169,8 @@ "pxeTemplateUpdatedSuccessfully": "PXE template updated successfully", "queueActions": "Queue actions", "queueActionsForFailedPcs": "Do you want to queue actions for PCs that cannot execute?", + "queueAllOffWarning": "All selected computers are turned off or disconnected. The action will be queued to run when they start.", + "queueMixedStatesWarning": "There are {{ on }} computers on and {{ off }} turned off or disconnected. It is recommended to queue so that turned-off computers execute the action on startup.", "RamUsage": "RAM Usage", "readOnly": "Read Only", "realTimeDhcpLogs": "Real-time DHCP server logs", diff --git a/ogWebconsole/src/locale/es.json b/ogWebconsole/src/locale/es.json index 3be2403..2321360 100644 --- a/ogWebconsole/src/locale/es.json +++ b/ogWebconsole/src/locale/es.json @@ -1169,6 +1169,8 @@ "pxeTemplateUpdatedSuccessfully": "Plantilla PXE actualizada con éxito", "queueActions": "Encolar acciones", "queueActionsForFailedPcs": "¿Quieres que se encolen las acciones para los PCs que no se pueda ejecutar?", + "queueAllOffWarning": "Todos los equipos seleccionados están apagados o desconectados. La acción se encolará para ejecutarse cuando arranquen.", + "queueMixedStatesWarning": "Hay {{ on }} equipos encendidos y {{ off }} apagados o desconectados. Se recomienda encolar para que los equipos apagados ejecuten la acción al encender.", "RamUsage": "Uso de RAM", "readOnly": "Solo Lectura", "realTimeDhcpLogs": "Logs en tiempo real del servidor DHCP",