feat(plugins): decouple plugin Mercure status and implement dynamic i18n support
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.18.0
Juan Manuel Bardallo 2026-10-23 09:48:31 +02:00
parent 9f34d2c234
commit f10613af82
10 changed files with 295 additions and 71 deletions

View File

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

View File

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

View File

@ -35,7 +35,7 @@
@if (group.plugin.icon) {
<mat-icon>{{ group.plugin.icon }}</mat-icon>
}
<span>{{ group.plugin.title }}</span>
<span>{{ group.plugin.titleKey ? (group.plugin.titleKey | translate) : group.plugin.title }}</span>
</button>
<mat-menu #pluginSubMenu="matMenu">
@ -44,7 +44,7 @@
@if (cmd.icon) {
<mat-icon>{{ cmd.icon }}</mat-icon>
}
<span>{{ cmd.label }}</span>
<span>{{ (cmd.translationKey || cmd.labelKey) ? ((cmd.translationKey || cmd.labelKey) | translate) : cmd.label }}</span>
</button>
}
</mat-menu>

View File

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

View File

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

View File

@ -423,7 +423,7 @@
RemotePC
</span>
}
@for (badge of getPluginBadges(client); track badge.id) {
@for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label) {
<span class="plugin-badge badge-{{badge.type}}" [matTooltip]="badge.tooltip">
<mat-icon class="plugin-badge-icon">{{ badge.icon }}</mat-icon> {{ badge.label }}
</span>
@ -554,7 +554,7 @@
RemotePC
</span>
}
@for (badge of getPluginBadges(client); track badge.id) {
@for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label) {
<span class="plugin-badge badge-{{badge.type}} list-view-badge" [matTooltip]="badge.tooltip">
<mat-icon class="plugin-badge-icon">{{ badge.icon }}</mat-icon> {{ badge.label }}
</span>

View File

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

View File

@ -6,11 +6,11 @@
</div>
<div class="plugin-header-info">
<div class="plugin-title-row">
<h2 class="plugin-title">{{ 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-status-tag">Activo</span>
</div>
<p class="plugin-description">{{ plugin.description }}</p>
<p class="plugin-description">{{ plugin.descriptionKey ? (plugin.descriptionKey | translate) : plugin.description }}</p>
<span class="plugin-author">Autor: {{ plugin.author }}</span>
</div>
</div>
@ -42,9 +42,9 @@
@for (field of schema; track field.key) {
<div class="form-field-card" [class.full-width]="field.type === 'textarea'">
<div class="field-meta">
<label class="field-label" [for]="'field-' + field.key">{{ field.label }}</label>
<label class="field-label" [for]="'field-' + field.key">{{ field.labelKey ? (field.labelKey | translate) : field.label }}</label>
@if (field.description) {
<span class="field-description">{{ field.description }}</span>
<span class="field-description">{{ field.descriptionKey ? (field.descriptionKey | translate) : field.description }}</span>
}
</div>

View File

@ -26,7 +26,7 @@
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-plugin-icon">{{ plugin.icon }}</mat-icon>
<span>{{ plugin.title }}</span>
<span>{{ plugin.titleKey ? (plugin.titleKey | translate) : plugin.title }}</span>
</ng-template>
<div class="tab-inner-content">
@ -42,7 +42,7 @@
<h3 class="empty-title">No hay plugins instalados</h3>
<p class="empty-desc">
Actualmente no hay ninguna extensión o plugin registrado en este servidor OpenGnsys.
Cuando instales un paquete compatible (como <code>ogfreeze</code>), aparecerá automáticamente aquí.
Cuando instales un paquete compatible, aparecerá automáticamente aquí.
</p>
</div>
}

View File

@ -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<string, Record<string, any>>;
schema: SchemaField[];
defaultConfig?: Record<string, any>;
config?: Record<string, any>;
@ -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<PluginManifest[]>([]);
public plugins$ = this.pluginsSubject.asObservable();
// Dynamic state cache (e.g. client freeze status: uuid/id -> 'FROZEN' | 'UNFROZEN')
private clientFreezeMap = new Map<string, 'FROZEN' | 'UNFROZEN'>();
// Dynamic state cache: key is `${pluginName}:${clientIdOrUuid}` -> status string
private clientPluginStatusMap = new Map<string, string>();
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<PluginManifest[]> {
@ -84,13 +149,103 @@ export class PluginService {
return this.http.get<PluginManifest[]>(`${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<Partial<PluginManifest>>(`${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<Record<string, any>>(`${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<void> {
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<any[]>(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<string, any> {
const plugin = this.getPlugin(pluginName);
const defaults = plugin?.defaultConfig || {};