fix(tooltips): actualizacion reactiva de tooltips y badges de plugins en tiempo real (v1.24.2)
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
Juan Manuel Bardallo 2026-10-30 14:24:20 +01:00
parent dbea3d2cd6
commit 8a82c37d48
7 changed files with 115 additions and 80 deletions

View File

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

View File

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

View File

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

View File

@ -423,7 +423,7 @@
RemotePC
</span>
}
@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 || '')) {
<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 + '-' + badge.type + '-' + badge.label) {
@for (badge of getPluginBadges(client); track badge.id + '-' + badge.type + '-' + badge.label + '-' + (badge.tooltip || '')) {
<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

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

View File

@ -92,7 +92,7 @@
<span class="image-date">Desplegada: {{ image.deployDate | date:'dd/MM/yyyy HH:mm' }}</span>
}
<div class="partition-badges-row">
@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 || '')) {
<span class="plugin-badge badge-{{badge.type}}" [matTooltip]="badge.tooltip">
<mat-icon class="plugin-badge-icon">{{ badge.icon }}</mat-icon> {{ badge.label }}
</span>

View File

@ -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<PluginManifest[]>([]);
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<string, string>();
// 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[] {