feat(plugins): implement declarative field.action contract standard and decouple script generation
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details

main 1.16.0
Juan Manuel Bardallo 2026-10-22 12:29:20 +02:00
parent 063762e1e4
commit 27a9cc3f7f
5 changed files with 54 additions and 83 deletions

View File

@ -7,7 +7,7 @@
- Incorporada infraestructura agnóstica para descubrimiento y gestión de plugins desacoplados a través de `/api/plugins`. - Incorporada infraestructura agnóstica para descubrimiento y gestión de plugins desacoplados a través de `/api/plugins`.
- Agrupación ordenada de comandos de plugins en submenús dedicados en la consola de comandos (`execute-command`), organizados por plugin para mantener el menú limpio. - Agrupación ordenada de comandos de plugins en submenús dedicados en la consola de comandos (`execute-command`), organizados por plugin para mantener el menú limpio.
- Comprobación y filtrado dinámico de compatibilidad por Sistema Operativo (`os: ['windows']`), deshabilitando automáticamente los comandos en puestos con sistemas incompatibles. - Comprobación y filtrado dinámico de compatibilidad por Sistema Operativo (`os: ['windows']`), deshabilitando automáticamente los comandos en puestos con sistemas incompatibles.
- Generador dinámico de formularios de configuración basados en esquemas JSON (`app-plugin-settings`) con botón de autogeneración y actualización reactiva de scripts según los parámetros seleccionados. - Estándar de Contrato y Acciones de Campos para Plugins (`PluginSettingsComponent`): Incorporado soporte para acciones declarativas por campo (`field.action`) ejecutadas contra el backend del plugin mediante el contrato estándar `{ config: ... }` -> `{ data: { [key]: value }, message: "..." }`. Los botones de asistencia (como la generación de scripts) se integran directamente en la cabecera de la caja del script y la lógica reside 100% en el backend del plugin, manteniendo el núcleo de `oggui` completamente agnóstico y desacoplado.
- Soporte de insignias de estado (badges) contextuales por cliente en las vistas de mosaico y lista de equipos (`groups.component`). - Soporte de insignias de estado (badges) contextuales por cliente en las vistas de mosaico y lista de equipos (`groups.component`).
- Nuevo elemento condicional "Plugins" en el menú lateral (`sidebar`), visible únicamente cuando existen plugins activos en el sistema. - Nuevo elemento condicional "Plugins" en el menú lateral (`sidebar`), visible únicamente cuando existen plugins activos en el sistema.
- Resaltador de sintaxis enriquecido y visor de terminal para scripts de configuración con numeración de líneas, colores semánticos (comentarios en verde itálica, comandos, parámetros, cadenas), botón de copiado rápido y alternancia entre vista coloreada y editor manual. - Resaltador de sintaxis enriquecido y visor de terminal para scripts de configuración con numeración de líneas, colores semánticos (comentarios en verde itálica, comandos, parámetros, cadenas), botón de copiado rápido y alternancia entre vista coloreada y editor manual.

View File

@ -271,6 +271,18 @@
border-color: #0284c7; border-color: #0284c7;
} }
.code-action-btn.action-helper {
background: rgba(14, 165, 233, 0.12);
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.35);
}
.code-action-btn.action-helper:hover {
background: rgba(14, 165, 233, 0.25);
color: #e0f2fe;
border-color: #38bdf8;
}
.action-btn-icon { .action-btn-icon {
font-size: 15px; font-size: 15px;
width: 15px; width: 15px;
@ -392,7 +404,7 @@
border-top: none; border-top: none;
} }
.btn-save, .btn-reset, .btn-regenerate { .btn-save, .btn-reset {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;

View File

@ -27,12 +27,6 @@
<mat-icon>restart_alt</mat-icon> <mat-icon>restart_alt</mat-icon>
Restaurar por defecto Restaurar por defecto
</button> </button>
@if (hasScriptFields()) {
<button mat-stroked-button color="accent" (click)="regenerateScripts()" class="btn-regenerate">
<mat-icon>auto_fix_high</mat-icon>
Regenerar Scripts desde Parámetros
</button>
}
<button mat-raised-button color="primary" (click)="save()" class="btn-save"> <button mat-raised-button color="primary" (click)="save()" class="btn-save">
<mat-icon>save</mat-icon> <mat-icon>save</mat-icon>
Guardar Configuración Guardar Configuración
@ -58,7 +52,7 @@
@switch (field.type) { @switch (field.type) {
@case ('select') { @case ('select') {
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()"> <mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
@for (opt of field.options; track opt) { @for (opt of field.options; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option> <mat-option [value]="opt">{{ opt }}</mat-option>
} }
@ -68,7 +62,7 @@
@case ('checkbox') { @case ('checkbox') {
<div class="toggle-wrapper"> <div class="toggle-wrapper">
<mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()"> <mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]">
{{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }} {{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }}
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
@ -76,13 +70,13 @@
@case ('number') { @case ('number') {
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()"> <input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field> </mat-form-field>
} }
@case ('text') { @case ('text') {
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]" (ngModelChange)="onParamChange()"> <input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field> </mat-form-field>
} }
@ -96,6 +90,12 @@
<span class="code-lang-badge">PowerShell</span> <span class="code-lang-badge">PowerShell</span>
</div> </div>
<div class="code-header-actions"> <div class="code-header-actions">
@if (field.action) {
<button type="button" class="code-action-btn action-helper" (click)="runFieldAction(field)" [title]="field.action.label">
<mat-icon class="action-btn-icon">{{ field.action.icon || 'auto_fix_high' }}</mat-icon>
<span>{{ field.action.label }}</span>
</button>
}
<button type="button" class="code-action-btn" (click)="copyToClipboard(configData[field.key])" title="Copiar script"> <button type="button" class="code-action-btn" (click)="copyToClipboard(configData[field.key])" title="Copiar script">
<mat-icon class="action-btn-icon">content_copy</mat-icon> <mat-icon class="action-btn-icon">content_copy</mat-icon>
<span>Copiar</span> <span>Copiar</span>
@ -125,7 +125,6 @@
class="code-raw-textarea" class="code-raw-textarea"
rows="9" rows="9"
[(ngModel)]="configData[field.key]" [(ngModel)]="configData[field.key]"
(ngModelChange)="onManualScriptInput(field.key)"
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
</div> </div>

View File

@ -1,5 +1,7 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PluginManifest, PluginService, SchemaField } from '@services/plugin.service'; import { PluginManifest, PluginService, SchemaField } from '@services/plugin.service';
import { ConfigService } from '@services/config.service';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -16,10 +18,11 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
schema: SchemaField[] = []; schema: SchemaField[] = [];
configData: Record<string, any> = {}; configData: Record<string, any> = {};
editingScripts: Record<string, boolean> = {}; editingScripts: Record<string, boolean> = {};
hasManualScriptEdits: boolean = false;
private pluginSub?: Subscription; private pluginSub?: Subscription;
private sanitizer = inject(DomSanitizer); private sanitizer = inject(DomSanitizer);
private http = inject(HttpClient);
private configService = inject(ConfigService);
constructor( constructor(
private pluginService: PluginService, private pluginService: PluginService,
@ -52,18 +55,26 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
} }
} }
hasScriptFields(): boolean { runFieldAction(field: SchemaField): void {
return this.schema.some(f => f.key === 'freeze_script' || f.key === 'unfreeze_script'); if (!field.action || !field.action.endpoint) return;
}
onParamChange(): void { const apiUrl = this.configService.apiUrl || '';
if (!this.hasManualScriptEdits) { const cleanEndpoint = field.action.endpoint.replace(/^\/api/, '');
this.regenerateScripts(false); const url = `${apiUrl}${cleanEndpoint}`;
}
}
onManualScriptInput(key: string): void { this.http.post<any>(url, { config: this.configData }).subscribe({
this.hasManualScriptEdits = true; next: (response) => {
if (response && response.data) {
this.configData = { ...this.configData, ...response.data };
}
const message = response?.message || `${field.action?.label} completado correctamente`;
this.toast.info(message, this.plugin?.title || 'Plugin');
},
error: (err) => {
const errorMsg = err?.error?.message || 'Error al ejecutar la acción del plugin';
this.toast.error(errorMsg, field.action?.label || 'Plugin');
}
});
} }
isEditingScript(key: string): boolean { isEditingScript(key: string): boolean {
@ -142,64 +153,6 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
return this.sanitizer.bypassSecurityTrustHtml(escaped); return this.sanitizer.bypassSecurityTrustHtml(escaped);
} }
regenerateScripts(showToast: boolean = true): void {
if (!this.pluginName) return;
if (this.pluginName === 'ogfreeze') {
const overlayType = this.configData['overlay_type'] || 'RAM';
const overlaySize = this.configData['overlay_size'] || 2048;
const protectC = this.configData['protect_c'] !== false;
// Obtener exclusiones de forma estricta (no usar fallback si el usuario las borró)
const rawExcl = this.configData['exclusions'];
const exclusionsRaw = (rawExcl !== undefined && rawExcl !== null) ? String(rawExcl).trim() : '';
const exclusions = exclusionsRaw.length > 0
? exclusionsRaw.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
: [];
let freezeScript = '';
let step = 1;
// Solo incluir el paso de exclusiones si el usuario ha especificado rutas
if (exclusions.length > 0) {
freezeScript += `# ${step}. Excluir metadatos y carpetas del filtro UWF\n`;
for (const excl of exclusions) {
freezeScript += `uwfmgr.exe file add-exclusion "${excl}"\n`;
}
freezeScript += `\n`;
step++;
}
freezeScript += `# ${step}. Configurar buffer temporal de escritura\n`;
freezeScript += `uwfmgr.exe overlay set-type ${overlayType}\n`;
freezeScript += `uwfmgr.exe overlay set-size ${overlaySize}\n\n`;
step++;
freezeScript += `# ${step}. Proteger volumen y habilitar filtro\n`;
if (protectC) {
freezeScript += `uwfmgr.exe volume protect C:\n`;
}
freezeScript += `uwfmgr.exe filter enable\n\n`;
step++;
freezeScript += `# ${step}. Reinicio obligatorio para que entre en vigor\n`;
freezeScript += `shutdown /r /t 5 -c "OpenGnsys: Reiniciando para aplicar congelacion de sistema"`;
this.configData['freeze_script'] = freezeScript;
this.configData['unfreeze_script'] = `# Desactivar filtro de protección UWF en el próximo inicio\nuwfmgr.exe filter disable\n\n# Reiniciar para aplicar cambios\nshutdown /r /t 5 -c "OpenGnsys: Reiniciando para descongelar sistema"`;
this.hasManualScriptEdits = false;
if (showToast) {
this.toast.info('Scripts de PowerShell generados a partir de los parámetros actuales', this.plugin?.title || 'Plugin');
}
} else {
if (showToast) {
this.toast.info('Scripts actualizados con los parámetros predeterminados', this.plugin?.title || 'Plugin');
}
}
}
save(): void { save(): void {
if (!this.pluginName) return; if (!this.pluginName) return;
this.pluginService.savePluginConfig(this.pluginName, this.configData); this.pluginService.savePluginConfig(this.pluginName, this.configData);
@ -209,7 +162,6 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
reset(): void { reset(): void {
if (!this.pluginName) return; if (!this.pluginName) return;
this.configData = this.pluginService.resetPluginConfig(this.pluginName); this.configData = this.pluginService.resetPluginConfig(this.pluginName);
this.hasManualScriptEdits = false;
this.toast.info('Configuración restaurada a valores por defecto', this.plugin?.title || 'Plugin'); this.toast.info('Configuración restaurada a valores por defecto', this.plugin?.title || 'Plugin');
} }
} }

View File

@ -22,6 +22,13 @@ export interface PluginBadge {
tooltip: string; tooltip: string;
} }
export interface FieldAction {
id: string;
label: string;
icon?: string;
endpoint: string;
}
export interface SchemaField { export interface SchemaField {
key: string; key: string;
label: string; label: string;
@ -29,6 +36,7 @@ export interface SchemaField {
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea'; type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea';
options?: string[]; options?: string[];
default: any; default: any;
action?: FieldAction;
} }
export interface PluginManifest { export interface PluginManifest {