From f10613af823725cf417e277718d7fdc3fad9f18f Mon Sep 17 00:00:00 2001 From: jmbardallo Date: Fri, 23 Oct 2026 09:48:31 +0200 Subject: [PATCH] feat(plugins): decouple plugin Mercure status and implement dynamic i18n support --- CHANGELOG.md | 12 + ogWebconsole/package.json | 2 +- .../execute-command.component.html | 4 +- .../execute-command.component.ts | 22 +- .../components/groups/groups.component.css | 38 ++- .../components/groups/groups.component.html | 4 +- .../app/components/groups/groups.component.ts | 2 + .../plugin-settings.component.html | 8 +- .../components/plugins/plugins.component.html | 4 +- .../src/app/services/plugin.service.ts | 270 +++++++++++++++--- 10 files changed, 295 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 380550b..5e5d29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.18.0] - 2026-10-23 + +### Added +- **Desacoplamiento Total de Plugins y Eventos Mercure (`PluginService`, `groups.component`, `execute-command.component`)**: + - `PluginService`: Suscripción dinámica y agnóstica al topic genérico de Mercure `plugins`, procesando eventos de cualquier plugin de forma desacoplada y actualizando el estado de los clientes en tiempo real sin hardcoding. + - Consulta agnóstica de endpoints de estado (`statusEndpoint`) y reglas de badges (`badges`) declaradas dinámicamente en los manifiestos de los plugins. + - Inferencia dinámica de badges contextuales en clientes (`getClientBadges`) mapeados directamente desde la especificación `badges` del manifiesto del plugin. + - Limpieza de estilos en `groups.component.css`, adoptando clases semánticas universales (`.badge-info`, `.badge-neutral`, `.badge-pending`, `.badge-error`, `.badge-success`, `.badge-warning`). +- **Sistema Estandarizado de Internacionalización Dinámica de Plugins (`PluginService`, `plugins.component`, `plugin-settings.component`, `execute-command.component`)**: + - Incorporada la carga e inyección en caliente de traducciones (`i18n`) desde el endpoint `/api/plugins/{name}/i18n/{lang}` de cada plugin directamente en `@ngx-translate`, sin necesidad de alterar los ficheros de idioma en disco ni requerir recompilación. + - Soporte completo para claves semánticas (`titleKey`, `descriptionKey`, `labelKey`, `tooltipKey`) en comandos, badges y formularios de configuración de plugins, con reactividad automática ante el cambio de idioma en la cabecera. + ## [1.17.2] - 2026-10-22 ### Revert diff --git a/ogWebconsole/package.json b/ogWebconsole/package.json index d3c85de..14b02c3 100644 --- a/ogWebconsole/package.json +++ b/ogWebconsole/package.json @@ -1,6 +1,6 @@ { "name": "og-webconsole", - "version": "1.14.0", + "version": "1.18.0", "scripts": { "ng": "ng", "start": "ng serve", 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 03ef622..21f9941 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 @@ -35,7 +35,7 @@ @if (group.plugin.icon) { {{ group.plugin.icon }} } - {{ group.plugin.title }} + {{ group.plugin.titleKey ? (group.plugin.titleKey | translate) : group.plugin.title }} @@ -44,7 +44,7 @@ @if (cmd.icon) { {{ cmd.icon }} } - {{ cmd.label }} + {{ (cmd.translationKey || cmd.labelKey) ? ((cmd.translationKey || cmd.labelKey) | translate) : cmd.label }} } 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 c02afb1..a393880 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 @@ -72,8 +72,12 @@ export class ExecuteCommandComponent implements OnInit { const allowed = this.getAllowedCommandsByRole(); this.arrayCommands = this.arrayCommands.filter(c => allowed.includes(c.slug)); - // Punto de anclaje para grupos de comandos aportados por plugins + // Punto de anclaje reactivo para grupos de comandos aportados por plugins this.pluginGroups = this.pluginService.getPluginGroups(); + this.pluginService.plugins$.subscribe(() => { + this.pluginGroups = this.pluginService.getPluginGroups(); + this.updateCommandStates(); + }); this.updateCommandStates(); } @@ -577,13 +581,14 @@ export class ExecuteCommandComponent implements OnInit { } private sendPluginCommandRequest(pluginCmd: PluginCommand, queue: boolean): void { - const defaultSlug = pluginCmd.slug.includes('unfreeze') ? 'unfreeze' : 'freeze'; - const endpoint = pluginCmd.endpoint || `/api/plugins/ogfreeze/${defaultSlug}`; - const cleanEndpoint = endpoint.replace(/^\/api/, ''); - const url = `${this.baseUrl}${cleanEndpoint}`; + if (!pluginCmd.endpoint) { + this.toastService.error('El comando no especifica un endpoint de ejecución', pluginCmd.label); + return; + } + const cleanEndpoint = pluginCmd.endpoint.replace(/^\/api/, ''); + const url = `${this.baseUrl}${cleanEndpoint}`; const clientIds = this.clientData.map(c => c.id || c['@id']); - const isFreeze = pluginCmd.slug.includes('freeze') && !pluginCmd.slug.includes('unfreeze'); this.http.post(url, { clients: clientIds, @@ -591,10 +596,7 @@ export class ExecuteCommandComponent implements OnInit { target_os: pluginCmd.os }).subscribe({ next: (response) => { - this.pluginService.toggleFreezeStatus(this.clientData, isFreeze); - const msg = response?.message || (isFreeze - ? 'Comando de congelación (UWF) procesado correctamente' - : 'Comando de descongelación (UWF) procesado correctamente'); + const msg = response?.message || 'Comando procesado correctamente'; this.toastService.success(msg, pluginCmd.label); }, error: (error) => { diff --git a/ogWebconsole/src/app/components/groups/groups.component.css b/ogWebconsole/src/app/components/groups/groups.component.css index 1c31626..62e2b24 100644 --- a/ogWebconsole/src/app/components/groups/groups.component.css +++ b/ogWebconsole/src/app/components/groups/groups.component.css @@ -2082,18 +2082,42 @@ mat-button-toggle-group { line-height: 14px; } -.plugin-badge.badge-frozen { - background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); - color: #0369a1; - border: 1px solid #7dd3fc; -} - -.plugin-badge.badge-unfrozen { +.plugin-badge.badge-neutral { background: #f1f5f9; color: #64748b; border: 1px solid #cbd5e1; } +.plugin-badge.badge-pending { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + color: #b45309; + border: 1px solid #fcd34d; +} + +.plugin-badge.badge-error { + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); + color: #b91c1c; + border: 1px solid #fca5a5; +} + +.plugin-badge.badge-info { + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + color: #1d4ed8; + border: 1px solid #93c5fd; +} + +.plugin-badge.badge-success { + background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); + color: #047857; + border: 1px solid #6ee7b7; +} + +.plugin-badge.badge-warning { + background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); + color: #b45309; + border: 1px solid #fde68a; +} + .plugin-badge.list-view-badge { margin-top: 2px; } diff --git a/ogWebconsole/src/app/components/groups/groups.component.html b/ogWebconsole/src/app/components/groups/groups.component.html index 5a367e0..d35f6ac 100644 --- a/ogWebconsole/src/app/components/groups/groups.component.html +++ b/ogWebconsole/src/app/components/groups/groups.component.html @@ -423,7 +423,7 @@ RemotePC } - @for (badge of getPluginBadges(client); track badge.id) { + @for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label) { {{ badge.icon }} {{ badge.label }} @@ -554,7 +554,7 @@ RemotePC } - @for (badge of getPluginBadges(client); track badge.id) { + @for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label) { {{ badge.icon }} {{ badge.label }} diff --git a/ogWebconsole/src/app/components/groups/groups.component.ts b/ogWebconsole/src/app/components/groups/groups.component.ts index 9810f8a..0ccea0a 100644 --- a/ogWebconsole/src/app/components/groups/groups.component.ts +++ b/ogWebconsole/src/app/components/groups/groups.component.ts @@ -557,6 +557,8 @@ export class GroupsComponent implements OnInit, OnDestroy { this.getStatusForAllClientsInNode(node); } + this.pluginService.loadActivePluginsStatus().subscribe(); + // Calcular estadísticas después de cargar los clientes this.calculateLocalStats(); }, diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html index 022eaf3..c890873 100644 --- a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html +++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html @@ -6,11 +6,11 @@
-

{{ plugin.title }}

+

{{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}

v{{ plugin.version }} Activo
-

{{ plugin.description }}

+

{{ plugin.descriptionKey ? (plugin.descriptionKey | translate) : plugin.description }}

Autor: {{ plugin.author }}
@@ -42,9 +42,9 @@ @for (field of schema; track field.key) {
- + @if (field.description) { - {{ field.description }} + {{ field.descriptionKey ? (field.descriptionKey | translate) : field.description }} }
diff --git a/ogWebconsole/src/app/components/plugins/plugins.component.html b/ogWebconsole/src/app/components/plugins/plugins.component.html index 02211d1..cb873d9 100644 --- a/ogWebconsole/src/app/components/plugins/plugins.component.html +++ b/ogWebconsole/src/app/components/plugins/plugins.component.html @@ -26,7 +26,7 @@ {{ plugin.icon }} - {{ plugin.title }} + {{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}
@@ -42,7 +42,7 @@

No hay plugins instalados

Actualmente no hay ninguna extensión o plugin registrado en este servidor OpenGnsys. - Cuando instales un paquete compatible (como ogfreeze), aparecerá automáticamente aquí. + Cuando instales un paquete compatible, aparecerá automáticamente aquí.

} diff --git a/ogWebconsole/src/app/services/plugin.service.ts b/ogWebconsole/src/app/services/plugin.service.ts index 65fbf3f..d9f91bc 100644 --- a/ogWebconsole/src/app/services/plugin.service.ts +++ b/ogWebconsole/src/app/services/plugin.service.ts @@ -1,12 +1,14 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { BehaviorSubject, Observable, forkJoin, of } from 'rxjs'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; import { ConfigService } from './config.service'; export interface PluginCommand { slug: string; translationKey?: string; + labelKey?: string; label: string; icon: string; disabled: boolean; @@ -14,9 +16,19 @@ export interface PluginCommand { os?: string[]; // Ej: ['windows'], ['linux'] } +export interface PluginBadgeRule { + status: string; + type: string; + icon: string; + label: string; + labelKey?: string; + tooltip: string; + tooltipKey?: string; +} + export interface PluginBadge { - id: string; - type: 'frozen' | 'unfrozen' | 'info'; + id: string; // Plugin name + type: string; icon: string; label: string; tooltip: string; @@ -32,7 +44,9 @@ export interface FieldAction { export interface SchemaField { key: string; label: string; + labelKey?: string; description?: string; + descriptionKey?: string; type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea'; options?: string[]; default: any; @@ -42,12 +56,17 @@ export interface SchemaField { export interface PluginManifest { name: string; title: string; + titleKey?: string; version: string; description: string; + descriptionKey?: string; author: string; icon: string; enabled: boolean; commands: PluginCommand[]; + statusEndpoint?: string; + badges?: PluginBadgeRule[]; + i18n?: Record>; schema: SchemaField[]; defaultConfig?: Record; config?: Record; @@ -64,16 +83,62 @@ export interface PluginGroup { export class PluginService { private http = inject(HttpClient); private configService = inject(ConfigService); + private translateService = inject(TranslateService); private activePlugins: PluginManifest[] = []; private pluginsSubject = new BehaviorSubject([]); public plugins$ = this.pluginsSubject.asObservable(); - // Dynamic state cache (e.g. client freeze status: uuid/id -> 'FROZEN' | 'UNFROZEN') - private clientFreezeMap = new Map(); + // Dynamic state cache: key is `${pluginName}:${clientIdOrUuid}` -> status string + private clientPluginStatusMap = new Map(); + private mercureEventSource: EventSource | null = null; constructor() { this.refreshPlugins().subscribe(); + this.initMercureSubscription(); + + // Re-aplicar traducciones dinámicas al cambiar de idioma + this.translateService.onLangChange.subscribe((event) => { + this.loadPluginTranslations(event.lang); + }); + } + + private getStatusKey(pluginName: string, idOrUuid: string | number): string { + return `${pluginName}:${idOrUuid}`; + } + + public setPluginStatus(pluginName: string, idOrUuid: string | number, status: string): void { + this.clientPluginStatusMap.set(this.getStatusKey(pluginName, idOrUuid), status); + } + + public getPluginStatus(pluginName: string, idOrUuid: string | number): string | undefined { + return this.clientPluginStatusMap.get(this.getStatusKey(pluginName, idOrUuid)); + } + + private initMercureSubscription(): void { + const mercureUrl = this.configService.mercureUrl; + if (!mercureUrl || this.mercureEventSource) { + return; + } + + try { + this.mercureEventSource = new EventSource(`${mercureUrl}?topic=` + encodeURIComponent('plugins')); + this.mercureEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data && data.plugin && data.status) { + if (data.clientId !== undefined && data.clientId !== null) { + this.setPluginStatus(data.plugin, String(data.clientId), data.status); + } + if (data.clientUuid) { + this.setPluginStatus(data.plugin, data.clientUuid, data.status); + } + } + } catch { + } + }; + } catch { + } } refreshPlugins(): Observable { @@ -84,13 +149,103 @@ export class PluginService { return this.http.get(`${apiUrl}/plugins`).pipe( catchError(() => of([])), - tap((plugins) => { - this.activePlugins = (plugins || []).filter(p => p.enabled); - this.pluginsSubject.next(this.activePlugins); + switchMap((plugins) => { + const active = (plugins || []).filter(p => p.enabled); + if (active.length === 0) { + this.activePlugins = []; + this.pluginsSubject.next([]); + return of([]); + } + + // Descubrir y fusionar manifiestos extendidos de cada plugin activo + const manifestRequests = active.map(p => + this.http.get>(`${apiUrl}/plugins/${p.name}/manifest`).pipe( + catchError(() => of({})), + map(extra => ({ ...p, ...extra })) + ) + ); + + return forkJoin(manifestRequests).pipe( + tap((fullPlugins) => { + this.activePlugins = fullPlugins; + this.pluginsSubject.next(this.activePlugins); + this.loadPluginTranslations(); + this.loadActivePluginsStatus().subscribe(); + }) + ); }) ); } + public loadPluginTranslations(lang?: string): void { + const currentLang = lang || this.translateService.currentLang || this.translateService.defaultLang || 'es'; + const apiUrl = this.configService.apiUrl; + + for (const plugin of this.activePlugins) { + // 1. Si el plugin declara i18n directamente en el manifest, lo inyectamos + if (plugin.i18n && typeof plugin.i18n === 'object') { + for (const [l, dict] of Object.entries(plugin.i18n)) { + if (dict && typeof dict === 'object') { + this.translateService.setTranslation(l, dict, true); + } + } + } + + // 2. Si el plugin tiene endpoint /i18n/{lang} (carpeta i18n/ estandarizada), lo descargamos bajo demanda + if (apiUrl) { + this.http.get>(`${apiUrl}/plugins/${plugin.name}/i18n/${currentLang}`).pipe( + catchError(() => of(null)), + tap((catalog) => { + if (catalog && typeof catalog === 'object' && Object.keys(catalog).length > 0) { + this.translateService.setTranslation(currentLang, catalog, true); + } + }) + ).subscribe(); + } + } + } + + loadActivePluginsStatus(): Observable { + const apiUrl = this.configService.apiUrl; + if (!apiUrl || this.activePlugins.length === 0) { + return of(undefined); + } + + const pluginsWithStatus = this.activePlugins.filter(p => !!p.statusEndpoint); + if (pluginsWithStatus.length === 0) { + return of(undefined); + } + + const requests = pluginsWithStatus.map(plugin => { + const endpoint = plugin.statusEndpoint!.startsWith('http') + ? plugin.statusEndpoint! + : `${apiUrl.replace(/\/api\/?$/, '')}${plugin.statusEndpoint!.startsWith('/') ? '' : '/'}${plugin.statusEndpoint}`; + + return this.http.get(endpoint).pipe( + catchError(() => of([])), + tap(rows => { + if (Array.isArray(rows)) { + for (const row of rows) { + const status = row.status; + const id = row.client_id ?? row.clientId ?? row.id; + const uuid = row.client_uuid ?? row.clientUuid ?? row.uuid; + if (status) { + if (id !== undefined && id !== null) { + this.setPluginStatus(plugin.name, String(id), status); + } + if (uuid) { + this.setPluginStatus(plugin.name, uuid, status); + } + } + } + } + }) + ); + }); + + return forkJoin(requests).pipe(map(() => undefined)); + } + getPlugins(): PluginManifest[] { return this.activePlugins.filter(p => p.enabled); } @@ -110,17 +265,32 @@ export class PluginService { getPluginGroups(): PluginGroup[] { return this.getPlugins() .filter(p => p.commands && p.commands.length > 0) - .map(p => ({ - plugin: p, - commands: p.commands - })); + .map(p => { + const translatedTitle = p.titleKey ? this.translateService.instant(p.titleKey) : p.title; + return { + plugin: { + ...p, + title: (translatedTitle && translatedTitle !== p.titleKey) ? translatedTitle : p.title + }, + commands: p.commands.map(cmd => { + const key = cmd.translationKey || cmd.labelKey; + if (key) { + const trans = this.translateService.instant(key); + if (trans && trans !== key) { + return { ...cmd, label: trans }; + } + } + return cmd; + }) + }; + }); } getCommands(): PluginCommand[] { const commands: PluginCommand[] = []; - for (const plugin of this.getPlugins()) { - if (plugin.commands && Array.isArray(plugin.commands)) { - commands.push(...plugin.commands); + for (const group of this.getPluginGroups()) { + if (group.commands && Array.isArray(group.commands)) { + commands.push(...group.commands); } } return commands; @@ -155,28 +325,50 @@ export class PluginService { getClientBadges(client: any): PluginBadge[] { const badges: PluginBadge[] = []; - if (!client) return badges; + if (!client || this.activePlugins.length === 0) return badges; - // Badges specific to active plugins (e.g., ogfreeze) - if (this.isPluginActive('ogfreeze')) { - const key = client.uuid || String(client.id) || client.name; - const status = this.clientFreezeMap.get(key) || client.freezeStatus; + const idKey = client.id ? String(client.id) : null; + const uuidKey = client.uuid || client.name; + + for (const plugin of this.activePlugins) { + if (!plugin.badges || plugin.badges.length === 0) { + continue; + } + + const currentStatus = (idKey ? this.getPluginStatus(plugin.name, idKey) : null) + || (uuidKey ? this.getPluginStatus(plugin.name, uuidKey) : null); + + if (!currentStatus) { + continue; + } + + const matchingBadge = plugin.badges.find( + b => b.status.toUpperCase() === currentStatus.toUpperCase() + ); + + if (matchingBadge) { + let label = matchingBadge.label; + if (matchingBadge.labelKey) { + const trans = this.translateService.instant(matchingBadge.labelKey); + if (trans && trans !== matchingBadge.labelKey) { + label = trans; + } + } + + let tooltip = matchingBadge.tooltip; + if (matchingBadge.tooltipKey) { + const trans = this.translateService.instant(matchingBadge.tooltipKey); + if (trans && trans !== matchingBadge.tooltipKey) { + tooltip = trans; + } + } - if (status === 'FROZEN') { badges.push({ - id: 'ogfreeze', - type: 'frozen', - icon: 'ac_unit', - label: 'Congelado', - tooltip: 'Equipo protegido contra escritura con UWF' - }); - } else if (status === 'UNFROZEN') { - badges.push({ - id: 'ogfreeze', - type: 'unfrozen', - icon: 'lock_open', - label: 'Descongelado', - tooltip: 'Filtro de protección UWF desactivado' + id: plugin.name, + type: matchingBadge.type, + icon: matchingBadge.icon, + label, + tooltip }); } } @@ -184,14 +376,6 @@ export class PluginService { return badges; } - toggleFreezeStatus(clients: any[], freeze: boolean): void { - const newStatus = freeze ? 'FROZEN' : 'UNFROZEN'; - for (const client of clients) { - const key = client.uuid || String(client.id) || client.name; - this.clientFreezeMap.set(key, newStatus); - } - } - getPluginConfig(pluginName: string): Record { const plugin = this.getPlugin(pluginName); const defaults = plugin?.defaultConfig || {};