feat(commands): add generic plugin command parameter modal and confirmation dialog (1.22.0)

main 1.22.0
Juan Manuel Bardallo 2026-10-27 00:52:59 +01:00
parent 4d755cca1f
commit f8bd02c316
8 changed files with 383 additions and 5 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## [1.22.0] - 2026-10-27
### Added
- **Diálogos Dinámicos de Parámetros y Confirmación en Comandos de Plugins (`PluginCommandModalComponent`, `ExecuteCommandComponent`)**:
- Incorporada especificación de parámetros interactivos (`params?: CommandParam[]`) y mensajes de confirmación (`confirm?: string; confirmKey?: string`) en la interfaz declarativa `PluginCommand`.
- Creado el componente modal genérico `PluginCommandModalComponent` que renderiza dinámicamente formularios reactivos con soporte para campos de texto, números, selectores y conmutadores (`checkbox`), respetando validaciones de obligatoriedad (`required`) e internacionalización con `@ngx-translate`.
- En `ExecuteCommandComponent`, al disparar un comando de cualquier plugin que requiera parámetros o confirmación, se abre automáticamente el modal interactivo, inyectando los valores resultantes en el cuerpo de la petición POST enviada al endpoint del plugin de forma 100% desacoplada.
- Total adhesión a la sintaxis moderna de control de flujo Angular (`@if`, `@for`, `@switch`) y separación estricta de estilos sin atributos inline.
## [1.21.1] - 2026-10-27
### Fixed

View File

@ -1,6 +1,6 @@
{
"name": "og-webconsole",
"version": "1.20.0",
"version": "1.22.0",
"scripts": {
"ng": "ng",
"start": "ng serve",

View File

@ -184,6 +184,7 @@ import { SoftwareProfilePartitionComponent } from './components/commands/main-co
import { ExecuteTaskNowComponent } from './components/commands/main-commands/execute-command/execute-task-now/execute-task-now.component';
import { ClientPendingTasksComponent } from './components/task-logs/client-pending-tasks/client-pending-tasks.component';
import { QueueConfirmationModalComponent } from './shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { PluginCommandModalComponent } from './shared/plugin-command-modal/plugin-command-modal.component';
import { ModalOverlayComponent } from './shared/modal-overlay/modal-overlay.component';
import { ScrollToTopComponent } from './shared/scroll-to-top/scroll-to-top.component';
import { CreateTagModalComponent } from './components/repositories/show-git-images/create-tag-modal/create-tag-modal.component';
@ -239,6 +240,7 @@ registerLocaleData(localeEs, 'es-ES');
DeleteModalComponent,
DeleteResultsModalComponent,
QueueConfirmationModalComponent,
PluginCommandModalComponent,
ClassroomViewComponent,
ClientViewComponent,
ShowOrganizationalUnitComponent,

View File

@ -11,6 +11,7 @@ import { RemoveCacheImageComponent } from "./remove-cache-image/remove-cache-ima
import { AuthService } from '@services/auth.service';
import {SoftwareProfilePartitionComponent} from "./software-profile-partition/software-profile-partition.component";
import { QueueConfirmationModalComponent } from '../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { PluginCommandModalComponent } from '../../../../shared/plugin-command-modal/plugin-command-modal.component';
import { ExecuteTaskNowComponent } from './execute-task-now/execute-task-now.component';
import { PluginService, PluginCommand } from '@services/plugin.service';
@ -542,6 +543,32 @@ export class ExecuteCommandComponent implements OnInit {
return;
}
const hasParams = !!(pluginCmd.params && pluginCmd.params.length > 0);
const hasConfirm = !!(pluginCmd.confirm || pluginCmd.confirmKey);
if (hasParams || hasConfirm) {
const dialogRef = this.dialog.open(PluginCommandModalComponent, {
width: '450px',
data: {
command: pluginCmd,
clients: this.clientData
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result || !result.confirmed) {
return; // Cancelado por el usuario
}
const customParams = result.params || {};
this.promptQueueAndSendPluginCommand(pluginCmd, customParams);
});
return;
}
this.promptQueueAndSendPluginCommand(pluginCmd, {});
}
private promptQueueAndSendPluginCommand(pluginCmd: PluginCommand, extraParams: Record<string, any>): void {
const requiredOs = (pluginCmd.os || []).map(os => os.toLowerCase());
// 1. Equipos en un sistema operativo no coincidente (ej. Linux u og-live cuando se requiere Windows)
@ -571,16 +598,16 @@ export class ExecuteCommandComponent implements OnInit {
return;
}
this.sendPluginCommandRequest(pluginCmd, queue);
this.sendPluginCommandRequest(pluginCmd, queue, extraParams);
});
return;
}
// 3. Todos los equipos están en línea en el sistema operativo correcto
this.sendPluginCommandRequest(pluginCmd, false);
this.sendPluginCommandRequest(pluginCmd, false, extraParams);
}
private sendPluginCommandRequest(pluginCmd: PluginCommand, queue: boolean): void {
private sendPluginCommandRequest(pluginCmd: PluginCommand, queue: boolean, extraParams: Record<string, any> = {}): void {
if (!pluginCmd.endpoint) {
this.toastService.error('El comando no especifica un endpoint de ejecución', pluginCmd.label);
return;
@ -593,7 +620,8 @@ export class ExecuteCommandComponent implements OnInit {
this.http.post<any>(url, {
clients: clientIds,
queue: queue,
target_os: pluginCmd.os
target_os: pluginCmd.os,
...extraParams
}).subscribe({
next: (response) => {
const msg = response?.message || 'Comando procesado correctamente';

View File

@ -5,6 +5,16 @@ import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { ConfigService } from './config.service';
export interface CommandParam {
key: string;
label: string;
labelKey?: string;
type: 'text' | 'number' | 'select' | 'checkbox';
options?: string[];
default?: any;
required?: boolean;
}
export interface PluginCommand {
slug: string;
translationKey?: string;
@ -14,6 +24,9 @@ export interface PluginCommand {
disabled: boolean;
endpoint?: string;
os?: string[]; // Ej: ['windows'], ['linux']
params?: CommandParam[];
confirm?: string;
confirmKey?: string;
}
export interface PluginBadgeRule {

View File

@ -0,0 +1,141 @@
.plugin-modal-header {
padding: 1.25rem 1.5rem 0.5rem 1.5rem;
}
.header-title-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
color: #1976d2;
font-size: 1.75rem;
width: 1.75rem;
height: 1.75rem;
}
.dialog-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.target-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
padding: 0.2rem 0.6rem;
background-color: var(--badge-bg, #f1f5f9);
color: var(--badge-color, #475569);
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 500;
}
.target-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
color: #64748b;
}
.plugin-modal-content {
padding: 1rem 1.5rem;
min-width: 360px;
}
.confirm-message-box {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
background-color: #fffbeb;
border-left: 4px solid #f59e0b;
border-radius: 6px;
margin-bottom: 1.25rem;
}
.confirm-icon {
color: #d97706;
font-size: 1.5rem;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
margin-top: 0.1rem;
}
.confirm-text {
margin: 0;
font-size: 0.95rem;
color: #92400e;
line-height: 1.4;
}
.params-form-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.param-row {
display: flex;
flex-direction: column;
}
.w-100 {
width: 100%;
}
.toggle-container {
padding: 0.5rem 0;
}
.plugin-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem 1.5rem;
}
.action-button {
background-color: #1976d2;
color: #ffffff;
padding: 0.5rem 1.25rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background-color 0.2s ease;
font-family: inherit;
}
.action-button:hover:not(:disabled) {
background-color: #1565c0;
}
.action-button:disabled {
background-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
}
.ordinary-button {
background-color: #f1f5f9;
color: #334155;
padding: 0.5rem 1.25rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background-color 0.2s ease;
font-family: inherit;
}
.ordinary-button:hover:not(:disabled) {
background-color: #e2e8f0;
}

View File

@ -0,0 +1,83 @@
<div class="plugin-modal-header">
<div class="header-title-row">
@if (command.icon) {
<mat-icon class="header-icon">{{ command.icon }}</mat-icon>
}
<h2 mat-dialog-title class="dialog-title">
{{ getCommandTitle() }}
</h2>
</div>
@if (getTargetDescription(); as target) {
<span class="target-badge">
<mat-icon class="target-icon">devices</mat-icon>
{{ target }}
</span>
}
</div>
<mat-dialog-content class="plugin-modal-content">
@if (getConfirmMessage(); as confirmMsg) {
<div class="confirm-message-box">
<mat-icon class="confirm-icon">warning_amber</mat-icon>
<p class="confirm-text">{{ confirmMsg }}</p>
</div>
}
@if (command.params && command.params.length > 0) {
<div class="params-form-grid">
@for (param of command.params; track param.key) {
<div class="param-row">
@switch (param.type) {
@case ('text') {
<mat-form-field appearance="outline" class="w-100">
<mat-label>{{ getParamLabel(param) }}</mat-label>
<input matInput [id]="'param-' + param.key" [(ngModel)]="formValues[param.key]" [required]="!!param.required">
@if (param.required) {
<mat-hint>Campo obligatorio</mat-hint>
}
</mat-form-field>
}
@case ('number') {
<mat-form-field appearance="outline" class="w-100">
<mat-label>{{ getParamLabel(param) }}</mat-label>
<input matInput type="number" [id]="'param-' + param.key" [(ngModel)]="formValues[param.key]" [required]="!!param.required">
@if (param.required) {
<mat-hint>Campo obligatorio</mat-hint>
}
</mat-form-field>
}
@case ('select') {
<mat-form-field appearance="outline" class="w-100">
<mat-label>{{ getParamLabel(param) }}</mat-label>
<mat-select [id]="'param-' + param.key" [(ngModel)]="formValues[param.key]" [required]="!!param.required">
@for (opt of param.options; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('checkbox') {
<div class="toggle-container">
<mat-slide-toggle [id]="'param-' + param.key" color="primary" [(ngModel)]="formValues[param.key]">
{{ getParamLabel(param) }}
</mat-slide-toggle>
</div>
}
}
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions class="plugin-modal-actions">
<button type="button" class="ordinary-button" (click)="onCancel()">
{{ 'buttonCancel' | translate }}
</button>
<button type="button" class="action-button" [disabled]="!isValid()" (click)="onSubmit()">
{{ getCommandTitle() }}
</button>
</mat-dialog-actions>

View File

@ -0,0 +1,102 @@
import { Component, Inject, OnInit, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { PluginCommand, CommandParam } from '../../services/plugin.service';
export interface PluginCommandModalData {
command: PluginCommand;
clients: any[];
}
@Component({
selector: 'app-plugin-command-modal',
templateUrl: './plugin-command-modal.component.html',
styleUrl: './plugin-command-modal.component.css'
})
export class PluginCommandModalComponent implements OnInit {
private translate = inject(TranslateService);
command: PluginCommand;
clients: any[] = [];
formValues: Record<string, any> = {};
constructor(
public dialogRef: MatDialogRef<PluginCommandModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: PluginCommandModalData
) {
this.command = data.command;
this.clients = data.clients || [];
}
ngOnInit(): void {
if (this.command.params) {
for (const param of this.command.params) {
this.formValues[param.key] = param.default !== undefined ? param.default : (param.type === 'checkbox' ? false : '');
}
}
}
getCommandTitle(): string {
const key = this.command.translationKey || this.command.labelKey;
if (key) {
const trans = this.translate.instant(key);
if (trans && trans !== key) return trans;
}
return this.command.label || this.command.slug;
}
getConfirmMessage(): string | null {
if (this.command.confirmKey) {
const trans = this.translate.instant(this.command.confirmKey);
if (trans && trans !== this.command.confirmKey) return trans;
}
return this.command.confirm || null;
}
getParamLabel(param: CommandParam): string {
if (param.labelKey) {
const trans = this.translate.instant(param.labelKey);
if (trans && trans !== param.labelKey) return trans;
}
return param.label || param.key;
}
getTargetDescription(): string {
if (!this.clients || this.clients.length === 0) {
return '';
}
if (this.clients.length === 1) {
return this.clients[0].name || this.clients[0].ip || 'Equipo seleccionado';
}
return `${this.clients.length} equipos seleccionados`;
}
isValid(): boolean {
if (!this.command.params || this.command.params.length === 0) {
return true;
}
for (const param of this.command.params) {
if (param.required) {
const val = this.formValues[param.key];
if (val === undefined || val === null || val === '') {
return false;
}
}
}
return true;
}
onCancel(): void {
this.dialogRef.close();
}
onSubmit(): void {
if (!this.isValid()) {
return;
}
this.dialogRef.close({
confirmed: true,
params: { ...this.formValues }
});
}
}