feat(commands): support offline client execution with auto-queueing and modernize components
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details

main 1.15.0
Juan Manuel Bardallo 2026-10-20 10:15:08 +02:00
parent 414076b45f
commit 5576757c7b
17 changed files with 255 additions and 121 deletions

View File

@ -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

View File

@ -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%;

View File

@ -1,66 +1,70 @@
<h2 mat-dialog-title> {{ 'selectPartitionToBootOs' | translate }}</h2>
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
@if (loading) {
<mat-spinner class="loading-spinner"></mat-spinner>
} @else {
<div class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> {{ 'labelClients' | translate }}</mat-panel-title>
<mat-panel-description>
{{ 'clientListBootOs' | translate }}<mat-icon>desktop_windows</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>
<div *ngIf="!loading" class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> {{ 'labelClients' | translate }}</mat-panel-title>
<mat-panel-description>
{{ 'clientListBootOs' | translate }}<mat-icon>desktop_windows</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="button-row">
<button class="action-button" (click)="toggleSelectAll()">
{{ allSelected ? 'Desmarcar todos' : 'Marcar todos' }}
</button>
</div>
<div class="clients-grid">
<div *ngFor="let client of data.clients" class="client-item">
<div class="client-card"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" >
<img
[src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon"
class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name | slice:0:20 }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
<mat-divider></mat-divider>
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="loadPartitions(selectedModelClient)">
<mat-radio-button [value]="client"
color="primary"
[disabled]="!client.selected"
(click)="$event.stopPropagation()">
{{ 'labelModel' | translate }}</mat-radio-button>
</mat-radio-group>
</div>
<div class="button-row">
<button class="action-button" (click)="toggleSelectAll()">
{{ allSelected ? 'Desmarcar todos' : 'Marcar todos' }}
</button>
</div>
</div>
</mat-expansion-panel>
</div>
<div class="clients-grid">
@for (client of data.clients; track client.uuid) {
<div class="client-item">
<div class="client-card"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" >
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<img
[src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon"
class="client-image" />
<div *ngIf="!loading" class="partition-table-container">
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
[partitions]="dataSource.data"
[columns]="columns"
[displayedColumns]="displayedColumns"
[disableCondition]="isPartitionDisabled">
</app-disk-partition-selector>
</div>
<div class="client-details">
<span class="client-name">{{ client.name | slice:0:20 }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
<mat-divider></mat-divider>
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="loadPartitions(selectedModelClient)">
<mat-radio-button [value]="client"
color="primary"
[disabled]="!client.selected"
(click)="$event.stopPropagation()">
{{ 'labelModel' | translate }}</mat-radio-button>
</mat-radio-group>
</div>
</div>
}
</div>
</mat-expansion-panel>
</div>
<mat-divider class="section-divider"></mat-divider>
<div class="partition-table-container">
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
[partitions]="dataSource.data"
[columns]="columns"
[displayedColumns]="displayedColumns"
[disableCondition]="isPartitionDisabled">
</app-disk-partition-selector>
</div>
}
</mat-dialog-content>
<div mat-dialog-actions class="action-container">

View File

@ -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() {

View File

@ -1,24 +1,27 @@
<ng-container [ngSwitch]="buttonType">
<button *ngSwitchCase="'icon'" mat-icon-button color="primary" [matMenuTriggerFor]="commandMenu"
[disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
</button>
<button class="action-button" [disabled]="clientData.length === 0 || disabled" *ngSwitchCase="'text'"
[matMenuTriggerFor]="commandMenu">
{{ buttonText }}
</button>
<button mat-menu-item *ngSwitchCase="'menu-item'" [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ buttonText }}</span>
</button>
</ng-container>
@switch (buttonType) {
@case ('icon') {
<button mat-icon-button color="primary" [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
</button>
}
@case ('text') {
<button class="action-button" [disabled]="clientData.length === 0 || disabled" [matMenuTriggerFor]="commandMenu">
{{ buttonText }}
</button>
}
@case ('menu-item') {
<button mat-menu-item [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ buttonText }}</span>
</button>
}
}
<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.translationKey | translate }}
</button>
@for (command of arrayCommands; track command.slug) {
<button mat-menu-item [disabled]="command.disabled || (command.slug === 'create-image' && clientData.length > 1)"
(click)="onCommandSelect(command.slug)">
{{ command.translationKey | translate }}
</button>
}
</mat-menu>

View File

@ -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<any>(`${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;

View File

@ -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 => {

View File

@ -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'));

View File

@ -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 => {

View File

@ -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 => {

View File

@ -281,10 +281,15 @@
<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 || ''"
[runScriptContext]="getRunScriptContext(selectedNode?.clients || [])">
<app-execute-command
[organizationalUnit]="selectedNode"
[showOnlyDirectClients]="showOnlyDirectClients"
[clientData]="selectedNode?.clients || []"
[buttonType]="'menu-item'"
[buttonText]="'ejecutarComandos' | translate"
[icon]="'terminal'"
[disabled]="!((selectedNode?.clients ?? []).length > 0)"
[runScriptContext]="selectedNode">
</app-execute-command>
</mat-menu>
</div>

View File

@ -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;
}

View File

@ -1,6 +1,12 @@
<h1 mat-dialog-title>{{ 'confirmationTitle' | translate }}</h1>
<div mat-dialog-content>
<p>{{ 'queueActionsForFailedPcs' | translate }}</p>
@if (allOff) {
<p class="queue-warning-info">{{ 'queueAllOffWarning' | translate }}</p>
} @else if (hasMixedStates) {
<p class="queue-warning-info">{{ 'queueMixedStatesWarning' | translate: { on: onCount, off: offCount } }}</p>
} @else {
<p>{{ 'queueActionsForFailedPcs' | translate }}</p>
}
<mat-checkbox [(ngModel)]="shouldQueue">{{ 'queueActions' | translate }}</mat-checkbox>
</div>
<div mat-dialog-actions class="action-container">

View File

@ -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<QueueConfirmationModalComponent>,
@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();
}

View File

@ -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",

View File

@ -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",

View File

@ -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",