feat: soporte para activar y desactivar plugins en caliente (1.19.0)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head This commit looks good Details

main 1.19.0
Juan Manuel Bardallo 2026-10-23 14:41:14 +02:00
parent f10613af82
commit 2aea3beab8
9 changed files with 89 additions and 12 deletions

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## [1.19.0] - 2026-10-23
### Added
- **Activación y Desactivación Dinámica de Plugins (`PluginService`, `plugins.component`, `plugin-settings.component`)**:
- Incorporada la capacidad de activar y desactivar plugins en caliente sin desinstalarlos del sistema.
- Al desactivar un plugin, sus comandos contextuales y badges se ocultan automáticamente de las tarjetas y menús de clientes en toda la consola web, preservando íntegra su configuración en base de datos.
- En la vista de administración `/plugins`, se integró un interruptor deslizante (`mat-slide-toggle`) en la cabecera de configuración para conmutar entre `Activo` e `Inactivo` con notificación toast y actualización reactiva.
- Añadido indicador visual de estado y conteo en la cabecera del panel (`X Activo(s) / Y Instalado(s)`) y badge `Inactivo` en las pestañas de los plugins deshabilitados.
## [1.18.0] - 2026-10-23 ## [1.18.0] - 2026-10-23
### Added ### Added

View File

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

View File

@ -70,6 +70,20 @@
border-radius: 12px; border-radius: 12px;
} }
.plugin-status-toggle {
margin-left: auto;
font-weight: 600;
font-size: 0.85rem;
}
.plugin-status-toggle.active {
color: #166534;
}
.plugin-status-toggle.inactive {
color: #64748b;
}
.plugin-description { .plugin-description {
margin: 6px 0 0 0; margin: 6px 0 0 0;
color: #475569; color: #475569;

View File

@ -8,7 +8,15 @@
<div class="plugin-title-row"> <div class="plugin-title-row">
<h2 class="plugin-title">{{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}</h2> <h2 class="plugin-title">{{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}</h2>
<span class="plugin-version-tag">v{{ plugin.version }}</span> <span class="plugin-version-tag">v{{ plugin.version }}</span>
<span class="plugin-status-tag">Activo</span> <mat-slide-toggle
[checked]="plugin.enabled"
(change)="toggleEnabled($event.checked)"
color="primary"
class="plugin-status-toggle"
[class.active]="plugin.enabled"
[class.inactive]="!plugin.enabled">
{{ plugin.enabled ? 'Activo' : 'Inactivo' }}
</mat-slide-toggle>
</div> </div>
<p class="plugin-description">{{ plugin.descriptionKey ? (plugin.descriptionKey | translate) : plugin.description }}</p> <p class="plugin-description">{{ plugin.descriptionKey ? (plugin.descriptionKey | translate) : plugin.description }}</p>
<span class="plugin-author">Autor: {{ plugin.author }}</span> <span class="plugin-author">Autor: {{ plugin.author }}</span>

View File

@ -55,6 +55,20 @@ export class PluginSettingsComponent implements OnInit, OnChanges, OnDestroy {
} }
} }
toggleEnabled(checked: boolean): void {
if (!this.pluginName) return;
this.pluginService.togglePlugin(this.pluginName, checked).subscribe({
next: () => {
const statusText = checked ? 'activado' : 'desactivado';
this.toast.success(`Plugin ${statusText} correctamente`, this.plugin?.title || 'Plugin');
},
error: () => {
this.toast.error('Error al cambiar el estado del plugin', this.plugin?.title || 'Plugin');
this.loadPluginData();
}
});
}
runFieldAction(field: SchemaField): void { runFieldAction(field: SchemaField): void {
if (!field.action || !field.action.endpoint) return; if (!field.action || !field.action.endpoint) return;

View File

@ -148,3 +148,14 @@
font-size: 0.95rem; font-size: 0.95rem;
margin: 0; margin: 0;
} }
.tab-disabled-badge {
margin-left: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 0.7rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid #cbd5e1;
}

View File

@ -5,7 +5,7 @@
<mat-icon class="header-title-icon">extension</mat-icon> <mat-icon class="header-title-icon">extension</mat-icon>
<h1 class="header-main-title">Plugins y Extensiones</h1> <h1 class="header-main-title">Plugins y Extensiones</h1>
@if (!isLoading) { @if (!isLoading) {
<span class="plugins-count-badge">{{ plugins.length }} Activo(s)</span> <span class="plugins-count-badge">{{ activePluginsCount }} Activo(s) / {{ plugins.length }} Instalado(s)</span>
} }
</div> </div>
<p class="header-subtitle"> <p class="header-subtitle">
@ -27,6 +27,9 @@
<ng-template mat-tab-label> <ng-template mat-tab-label>
<mat-icon class="tab-plugin-icon">{{ plugin.icon }}</mat-icon> <mat-icon class="tab-plugin-icon">{{ plugin.icon }}</mat-icon>
<span>{{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}</span> <span>{{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}</span>
@if (!plugin.enabled) {
<span class="tab-disabled-badge">Inactivo</span>
}
</ng-template> </ng-template>
<div class="tab-inner-content"> <div class="tab-inner-content">

View File

@ -14,11 +14,15 @@ export class PluginsComponent implements OnInit, OnDestroy {
private pluginSub?: Subscription; private pluginSub?: Subscription;
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
get activePluginsCount(): number {
return this.plugins.filter(p => p.enabled).length;
}
constructor(public pluginService: PluginService) {} constructor(public pluginService: PluginService) {}
ngOnInit(): void { ngOnInit(): void {
// 1. Asignar estado actual en memoria si ya existe // 1. Asignar estado actual en memoria si ya existe
this.plugins = this.pluginService.getPlugins(); this.plugins = this.pluginService.getAllPlugins();
if (this.plugins.length > 0) { if (this.plugins.length > 0) {
this.isLoading = false; this.isLoading = false;
} }

View File

@ -85,6 +85,7 @@ export class PluginService {
private configService = inject(ConfigService); private configService = inject(ConfigService);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private allPlugins: PluginManifest[] = [];
private activePlugins: PluginManifest[] = []; private activePlugins: PluginManifest[] = [];
private pluginsSubject = new BehaviorSubject<PluginManifest[]>([]); private pluginsSubject = new BehaviorSubject<PluginManifest[]>([]);
public plugins$ = this.pluginsSubject.asObservable(); public plugins$ = this.pluginsSubject.asObservable();
@ -147,18 +148,18 @@ export class PluginService {
return of([]); return of([]);
} }
return this.http.get<PluginManifest[]>(`${apiUrl}/plugins`).pipe( return this.http.get<PluginManifest[]>(`${apiUrl}/plugins?all=true`).pipe(
catchError(() => of([])), catchError(() => of([])),
switchMap((plugins) => { switchMap((plugins) => {
const active = (plugins || []).filter(p => p.enabled); if (!plugins || plugins.length === 0) {
if (active.length === 0) { this.allPlugins = [];
this.activePlugins = []; this.activePlugins = [];
this.pluginsSubject.next([]); this.pluginsSubject.next([]);
return of([]); return of([]);
} }
// Descubrir y fusionar manifiestos extendidos de cada plugin activo // Descubrir y fusionar manifiestos extendidos de cada plugin
const manifestRequests = active.map(p => const manifestRequests = plugins.map(p =>
this.http.get<Partial<PluginManifest>>(`${apiUrl}/plugins/${p.name}/manifest`).pipe( this.http.get<Partial<PluginManifest>>(`${apiUrl}/plugins/${p.name}/manifest`).pipe(
catchError(() => of({})), catchError(() => of({})),
map(extra => ({ ...p, ...extra })) map(extra => ({ ...p, ...extra }))
@ -167,8 +168,9 @@ export class PluginService {
return forkJoin(manifestRequests).pipe( return forkJoin(manifestRequests).pipe(
tap((fullPlugins) => { tap((fullPlugins) => {
this.activePlugins = fullPlugins; this.allPlugins = fullPlugins;
this.pluginsSubject.next(this.activePlugins); this.activePlugins = fullPlugins.filter(p => p.enabled);
this.pluginsSubject.next(this.allPlugins);
this.loadPluginTranslations(); this.loadPluginTranslations();
this.loadActivePluginsStatus().subscribe(); this.loadActivePluginsStatus().subscribe();
}) })
@ -177,6 +179,14 @@ export class PluginService {
); );
} }
togglePlugin(name: string, enabled?: boolean): Observable<any> {
const apiUrl = this.configService.apiUrl;
if (!apiUrl) return of(null);
return this.http.post(`${apiUrl}/plugins/${name}/toggle`, enabled !== undefined ? { enabled } : {}).pipe(
switchMap(() => this.refreshPlugins())
);
}
public loadPluginTranslations(lang?: string): void { public loadPluginTranslations(lang?: string): void {
const currentLang = lang || this.translateService.currentLang || this.translateService.defaultLang || 'es'; const currentLang = lang || this.translateService.currentLang || this.translateService.defaultLang || 'es';
const apiUrl = this.configService.apiUrl; const apiUrl = this.configService.apiUrl;
@ -250,6 +260,10 @@ export class PluginService {
return this.activePlugins.filter(p => p.enabled); return this.activePlugins.filter(p => p.enabled);
} }
getAllPlugins(): PluginManifest[] {
return this.allPlugins;
}
hasPlugins(): boolean { hasPlugins(): boolean {
return this.getPlugins().length > 0; return this.getPlugins().length > 0;
} }
@ -259,7 +273,7 @@ export class PluginService {
} }
getPlugin(name: string): PluginManifest | undefined { getPlugin(name: string): PluginManifest | undefined {
return this.activePlugins.find(p => p.name === name); return this.allPlugins.find(p => p.name === name);
} }
getPluginGroups(): PluginGroup[] { getPluginGroups(): PluginGroup[] {