diff --git a/CHANGELOG.md b/CHANGELOG.md
index efbbc9d..3a4417e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## [1.24.2] - 2026-10-30
+
+### Fixed
+- **Actualización Reactiva en Tiempo Real de Tooltips y Badges de Plugins (`PluginService`, `groups.component`, `execute-command.component`)**:
+ - Solucionado el problema por el cual el tiempo restante en el tooltip de los badges no se actualizaba en vivo al recibir eventos Mercure o tras ejecutar acciones como "Renovar tiempo", requiriendo recargar la página manualmente.
+ - La recepción de eventos SSE de Mercure en `PluginService` se ejecuta ahora dentro de `NgZone.run()`, emitiendo a través del nuevo flujo observable reactivo `statusUpdates$`.
+ - Suscripción reactiva en `GroupsComponent` a `pluginService.statusUpdates$`, activando la detección de cambios (`cd.detectChanges()`) de manera inmediata ante cualquier actualización de estado o metadatos de puestos.
+ - En `groups.component.html` y `client-details.component.html`, la expresión de seguimiento del control de flujo (`@for`) incorpora ahora el tooltip (`track ... + (badge.tooltip || '')`), forzando la actualización instantánea del enlace `[matTooltip]` en el DOM al cambiar los minutos restantes o datos del alumno.
+ - Tras procesar con éxito un comando de plugin en `ExecuteCommandComponent`, se dispara automáticamente una recarga de estados en segundo plano (`loadActivePluginsStatus()`).
+
## [1.24.1] - 2026-10-30
### Fixed
diff --git a/ogWebconsole/package.json b/ogWebconsole/package.json
index 2db9697..33cf26f 100644
--- a/ogWebconsole/package.json
+++ b/ogWebconsole/package.json
@@ -1,6 +1,6 @@
{
"name": "og-webconsole",
- "version": "1.24.1",
+ "version": "1.24.2",
"scripts": {
"ng": "ng",
"start": "ng serve",
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 6541347..57169bf 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
@@ -626,6 +626,7 @@ export class ExecuteCommandComponent implements OnInit {
next: (response) => {
const msg = response?.message || 'Comando procesado correctamente';
this.toastService.success(msg, pluginCmd.label);
+ this.pluginService.loadActivePluginsStatus().subscribe();
},
error: (error) => {
const errorMsg = error?.error?.message || error?.error?.['hydra:description'] || 'Error al comunicar con el servidor';
diff --git a/ogWebconsole/src/app/components/groups/groups.component.html b/ogWebconsole/src/app/components/groups/groups.component.html
index d35f6ac..48a1cc1 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 + '-' + badge.type + '-' + badge.label) {
+ @for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label + '-' + (badge.tooltip || '')) {
{{ badge.icon }} {{ badge.label }}
@@ -554,7 +554,7 @@
RemotePC
}
- @for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label) {
+ @for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label + '-' + (badge.tooltip || '')) {
{{ 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 0ccea0a..28981bd 100644
--- a/ogWebconsole/src/app/components/groups/groups.component.ts
+++ b/ogWebconsole/src/app/components/groups/groups.component.ts
@@ -270,7 +270,13 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.breakpointObserver.observe(['(max-width: 992px)']).subscribe((result) => {
this.isSmallScreen = result.matches;
- })
+ });
+
+ this.subscriptions.add(
+ this.pluginService.statusUpdates$.subscribe(() => {
+ this.cd.detectChanges();
+ })
+ );
}
private updateClientStatus(clientUuid: string, status: string): void {
diff --git a/ogWebconsole/src/app/components/groups/shared/client-details/client-details.component.html b/ogWebconsole/src/app/components/groups/shared/client-details/client-details.component.html
index 8a51a55..b14bd04 100644
--- a/ogWebconsole/src/app/components/groups/shared/client-details/client-details.component.html
+++ b/ogWebconsole/src/app/components/groups/shared/client-details/client-details.component.html
@@ -92,7 +92,7 @@
Desplegada: {{ image.deployDate | date:'dd/MM/yyyy HH:mm' }}
}
- @for (badge of getPartitionBadges(disk.diskNumber, image.partitionNumber); track badge.id + '-' + badge.type + '-' + badge.label) {
+ @for (badge of getPartitionBadges(disk.diskNumber, image.partitionNumber); track badge.id + '-' + badge.type + '-' + badge.label + '-' + (badge.tooltip || '')) {
{{ badge.icon }} {{ badge.label }}
diff --git a/ogWebconsole/src/app/services/plugin.service.ts b/ogWebconsole/src/app/services/plugin.service.ts
index 68484f6..99bf27e 100644
--- a/ogWebconsole/src/app/services/plugin.service.ts
+++ b/ogWebconsole/src/app/services/plugin.service.ts
@@ -1,6 +1,6 @@
-import { Injectable, inject } from '@angular/core';
+import { Injectable, NgZone, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
-import { BehaviorSubject, Observable, forkJoin, of } from 'rxjs';
+import { BehaviorSubject, Observable, Subject, forkJoin, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { ConfigService } from './config.service';
@@ -115,12 +115,16 @@ export class PluginService {
private configService = inject(ConfigService);
private translateService = inject(TranslateService);
private authService = inject(AuthService);
+ private ngZone = inject(NgZone);
private allPlugins: PluginManifest[] = [];
private activePlugins: PluginManifest[] = [];
private pluginsSubject = new BehaviorSubject
([]);
public plugins$ = this.pluginsSubject.asObservable();
+ private statusUpdatesSubject = new Subject<{ plugin: string; clientId?: string; status: string; metadata?: any }>();
+ public statusUpdates$ = this.statusUpdatesSubject.asObservable();
+
// Dynamic state cache: key is `${pluginName}:${clientIdOrUuid}` -> status string
private clientPluginStatusMap = new Map();
// Dynamic metadata cache: key is `${pluginName}:${clientIdOrUuid}` -> extra attributes (e.g. dni, remaining_minutes)
@@ -184,95 +188,104 @@ export class PluginService {
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) {
- const disk = data.disk ?? data.disk_number ?? data.diskNumber;
- const part = data.partition ?? data.partition_number ?? data.partitionNumber;
+ this.ngZone.run(() => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data && data.plugin && data.status) {
+ const disk = data.disk ?? data.disk_number ?? data.diskNumber;
+ const part = data.partition ?? data.partition_number ?? data.partitionNumber;
- const clearEntriesForPrefix = (prefix: string) => {
- for (const key of Array.from(this.clientPluginStatusMap.keys())) {
- if (key.startsWith(prefix)) {
- this.clientPluginStatusMap.delete(key);
+ const clearEntriesForPrefix = (prefix: string) => {
+ for (const key of Array.from(this.clientPluginStatusMap.keys())) {
+ if (key.startsWith(prefix)) {
+ this.clientPluginStatusMap.delete(key);
+ }
}
- }
- for (const key of Array.from(this.clientPluginMetadataMap.keys())) {
- if (key.startsWith(prefix)) {
- this.clientPluginMetadataMap.delete(key);
+ for (const key of Array.from(this.clientPluginMetadataMap.keys())) {
+ if (key.startsWith(prefix)) {
+ this.clientPluginMetadataMap.delete(key);
+ }
}
- }
- };
+ };
- if (data.status === 'NONE') {
- if (data.clientId !== undefined && data.clientId !== null) {
- const idStr = String(data.clientId);
- this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, idStr));
- if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
- this.clientPluginStatusMap.delete(this.getPartitionStatusKey(data.plugin, idStr, Number(disk), Number(part)));
- const curGlobal = this.clientPluginStatusMap.get(this.getStatusKey(data.plugin, idStr));
- if (curGlobal && ['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING'].includes(curGlobal.toUpperCase())) {
+ if (data.status === 'NONE') {
+ if (data.clientId !== undefined && data.clientId !== null) {
+ const idStr = String(data.clientId);
+ this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, idStr));
+ if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
+ this.clientPluginStatusMap.delete(this.getPartitionStatusKey(data.plugin, idStr, Number(disk), Number(part)));
+ const curGlobal = this.clientPluginStatusMap.get(this.getStatusKey(data.plugin, idStr));
+ if (curGlobal && ['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING'].includes(curGlobal.toUpperCase())) {
+ this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, idStr));
+ }
+ } else if (disk !== undefined && disk !== null) {
+ clearEntriesForPrefix(`${data.plugin}:${idStr}:${disk}:`);
+ } else {
this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, idStr));
+ clearEntriesForPrefix(`${data.plugin}:${idStr}:`);
}
- } else if (disk !== undefined && disk !== null) {
- clearEntriesForPrefix(`${data.plugin}:${idStr}:${disk}:`);
- } else {
- this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, idStr));
- clearEntriesForPrefix(`${data.plugin}:${idStr}:`);
}
- }
- if (data.clientUuid) {
- const uuidStr = data.clientUuid;
- this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, uuidStr));
- if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
- this.clientPluginStatusMap.delete(this.getPartitionStatusKey(data.plugin, uuidStr, Number(disk), Number(part)));
- const curGlobal = this.clientPluginStatusMap.get(this.getStatusKey(data.plugin, uuidStr));
- if (curGlobal && ['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING'].includes(curGlobal.toUpperCase())) {
+ if (data.clientUuid) {
+ const uuidStr = data.clientUuid;
+ this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, uuidStr));
+ if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
+ this.clientPluginStatusMap.delete(this.getPartitionStatusKey(data.plugin, uuidStr, Number(disk), Number(part)));
+ const curGlobal = this.clientPluginStatusMap.get(this.getStatusKey(data.plugin, uuidStr));
+ if (curGlobal && ['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING'].includes(curGlobal.toUpperCase())) {
+ this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, uuidStr));
+ }
+ } else if (disk !== undefined && disk !== null) {
+ clearEntriesForPrefix(`${data.plugin}:${uuidStr}:${disk}:`);
+ } else {
this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, uuidStr));
+ clearEntriesForPrefix(`${data.plugin}:${uuidStr}:`);
}
- } else if (disk !== undefined && disk !== null) {
- clearEntriesForPrefix(`${data.plugin}:${uuidStr}:${disk}:`);
- } else {
- this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, uuidStr));
- clearEntriesForPrefix(`${data.plugin}:${uuidStr}:`);
}
- }
- } else {
- // Extraer metadatos contextuales (p. ej. dni, remainingMinutes, remaining_minutes)
- const metadata: any = {};
- for (const [k, v] of Object.entries(data)) {
- if (!['plugin', 'status', 'clientId', 'clientUuid', 'disk', 'disk_number', 'diskNumber', 'partition', 'partition_number', 'partitionNumber', 'action', 'timestamp'].includes(k) && v !== null && v !== undefined) {
- metadata[k] = v;
+ this.statusUpdatesSubject.next({ plugin: data.plugin, status: 'NONE' });
+ } else {
+ // Extraer metadatos contextuales (p. ej. dni, remainingMinutes, remaining_minutes)
+ const metadata: any = {};
+ for (const [k, v] of Object.entries(data)) {
+ if (!['plugin', 'status', 'clientId', 'clientUuid', 'disk', 'disk_number', 'diskNumber', 'partition', 'partition_number', 'partitionNumber', 'action', 'timestamp'].includes(k) && v !== null && v !== undefined) {
+ metadata[k] = v;
+ }
}
- }
- const hasMeta = Object.keys(metadata).length > 0;
+ const hasMeta = Object.keys(metadata).length > 0;
- if (data.clientId !== undefined && data.clientId !== null) {
- const idStr = String(data.clientId);
- this.setPluginStatus(data.plugin, idStr, data.status);
- if (hasMeta && data.status !== 'FREE') {
- this.setPluginMetadata(data.plugin, idStr, metadata);
- } else if (data.status === 'FREE') {
- this.clearPluginMetadata(data.plugin, idStr);
+ if (data.clientId !== undefined && data.clientId !== null) {
+ const idStr = String(data.clientId);
+ this.setPluginStatus(data.plugin, idStr, data.status);
+ if (hasMeta && data.status !== 'FREE') {
+ this.setPluginMetadata(data.plugin, idStr, metadata);
+ } else if (data.status === 'FREE') {
+ this.clearPluginMetadata(data.plugin, idStr);
+ }
+ if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
+ this.setPartitionPluginStatus(data.plugin, idStr, Number(disk), Number(part), data.status);
+ }
}
- if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
- this.setPartitionPluginStatus(data.plugin, idStr, Number(disk), Number(part), data.status);
- }
- }
- if (data.clientUuid) {
- this.setPluginStatus(data.plugin, data.clientUuid, data.status);
- if (hasMeta && data.status !== 'FREE') {
- this.setPluginMetadata(data.plugin, data.clientUuid, metadata);
- } else if (data.status === 'FREE') {
- this.clearPluginMetadata(data.plugin, data.clientUuid);
- }
- if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
- this.setPartitionPluginStatus(data.plugin, data.clientUuid, Number(disk), Number(part), data.status);
+ if (data.clientUuid) {
+ this.setPluginStatus(data.plugin, data.clientUuid, data.status);
+ if (hasMeta && data.status !== 'FREE') {
+ this.setPluginMetadata(data.plugin, data.clientUuid, metadata);
+ } else if (data.status === 'FREE') {
+ this.clearPluginMetadata(data.plugin, data.clientUuid);
+ }
+ if (disk !== undefined && disk !== null && part !== undefined && part !== null) {
+ this.setPartitionPluginStatus(data.plugin, data.clientUuid, Number(disk), Number(part), data.status);
+ }
}
+ this.statusUpdatesSubject.next({
+ plugin: data.plugin,
+ clientId: data.clientId ? String(data.clientId) : data.clientUuid,
+ status: data.status,
+ metadata
+ });
}
}
+ } catch {
}
- } catch {
- }
+ });
};
} catch {
}
@@ -422,7 +435,12 @@ export class PluginService {
);
});
- return forkJoin(requests).pipe(map(() => undefined));
+ return forkJoin(requests).pipe(
+ tap(() => {
+ this.statusUpdatesSubject.next({ plugin: 'all', status: 'REFRESHED' });
+ }),
+ map(() => undefined)
+ );
}
getPlugins(): PluginManifest[] {