refactor(plugin): modularize PluginService into specialized sub-services and eliminate badge duplication
parent
bfc7e07e2a
commit
a5e565aced
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -1,5 +1,17 @@
|
|||
# Changelog
|
||||
|
||||
## [1.25.1] - 2026-11-01
|
||||
|
||||
### Refactored
|
||||
- **Modularización y Optimización de Arquitectura en `PluginService`**:
|
||||
- Descompuesto el monolito de ~900 líneas en submódulos especializados bajo `src/app/services/plugin/`:
|
||||
- `plugin.types.ts`: Modelos, interfaces tipadas y estructuras de datos (`PluginCommand`, `RoleDefinition`, `PluginBadgeRule`, `PluginManifest`, etc.).
|
||||
- `plugin-state.service.ts`: Manejo reactivo del estado dinámico de plugins en memoria, caché de metadatos contextuales y suscripción a eventos en tiempo real vía Mercure (Server-Sent Events).
|
||||
- `plugin-roles.service.ts`: Comprobación de roles y permisos granulares de usuarios y comandos, jerarquía de acceso y sincronización con `ogCore`.
|
||||
- `plugin-badge.service.ts`: Resolución desacoplada de badges visuales e internacionalización de tooltips dinámicos con metadatos.
|
||||
- Eliminada la duplicación completa (~100 líneas) entre `getClientBadges()` y `getPartitionBadges()`, unificando la lógica de interpolación y renderizado en una función auxiliar común (`buildBadge`).
|
||||
- `PluginService` actúa ahora como una fachada limpia, liviana y 100% retrocompatible (~280 líneas), preservando sin alteraciones todas las firmas públicas y re-exportando los tipos para los componentes consumidores.
|
||||
|
||||
## [1.25.0] - 2026-11-01
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "og-webconsole",
|
||||
"version": "1.25.0",
|
||||
"version": "1.25.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
|
|
|
|||
|
|
@ -1,120 +1,23 @@
|
|||
import { Injectable, NgZone, inject } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, Subject, forkJoin, of } from 'rxjs';
|
||||
import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
|
||||
import { catchError, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ConfigService } from './config.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ConfigService } from '@services/config.service';
|
||||
|
||||
export interface CommandParam {
|
||||
key: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
type: 'text' | 'number' | 'select' | 'checkbox';
|
||||
options?: string[];
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
}
|
||||
import {
|
||||
PluginCommand,
|
||||
RoleDefinition,
|
||||
PluginBadge,
|
||||
PluginManifest,
|
||||
PluginGroup
|
||||
} from './plugin/plugin.types';
|
||||
import { PluginStateService } from './plugin/plugin-state.service';
|
||||
import { PluginBadgeService } from './plugin/plugin-badge.service';
|
||||
import { PluginRolesService } from './plugin/plugin-roles.service';
|
||||
|
||||
export interface PluginCommand {
|
||||
slug: string;
|
||||
translationKey?: string;
|
||||
labelKey?: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
disabled: boolean;
|
||||
endpoint?: string;
|
||||
os?: string[]; // Ej: ['windows'], ['linux']
|
||||
params?: CommandParam[];
|
||||
confirm?: string;
|
||||
confirmKey?: string;
|
||||
roles?: string[]; // Roles autorizados por defecto (ej. ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN'])
|
||||
requireStatus?: string[]; // Estados requeridos del plugin para habilitar el comando
|
||||
excludeStatus?: string[]; // Estados del plugin que deshabilitan el comando
|
||||
}
|
||||
|
||||
export interface RoleDefinition {
|
||||
key: string; // Clave del rol Symfony (ej: 'ROLE_SUPER_ADMIN')
|
||||
name: string; // Nombre descriptivo (ej: 'Super Admin')
|
||||
shortCode?: string;// Código corto usado en frontend (ej: 'super-admin')
|
||||
}
|
||||
|
||||
export interface PluginBadgeField {
|
||||
key: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface PluginBadgeRule {
|
||||
status: string;
|
||||
type: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
tooltip: string;
|
||||
tooltipKey?: string;
|
||||
tooltipFields?: PluginBadgeField[];
|
||||
}
|
||||
|
||||
export interface PluginBadge {
|
||||
id: string; // Plugin name
|
||||
type: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export interface FieldAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export interface SchemaField {
|
||||
key: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
description?: string;
|
||||
descriptionKey?: string;
|
||||
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea';
|
||||
language?: string;
|
||||
options?: string[];
|
||||
default: any;
|
||||
action?: FieldAction;
|
||||
}
|
||||
|
||||
export interface RecordColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
recordsEndpoint?: string;
|
||||
recordsColumns?: RecordColumn[];
|
||||
badges?: PluginBadgeRule[];
|
||||
i18n?: Record<string, Record<string, any>>;
|
||||
schema: SchemaField[];
|
||||
defaultConfig?: Record<string, any>;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PluginGroup {
|
||||
plugin: PluginManifest;
|
||||
commands: PluginCommand[];
|
||||
}
|
||||
// Re-exportar todos los tipos para compatibilidad transparente con componentes existentes
|
||||
export * from './plugin/plugin.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
@ -123,26 +26,20 @@ export class PluginService {
|
|||
private http = inject(HttpClient);
|
||||
private configService = inject(ConfigService);
|
||||
private translateService = inject(TranslateService);
|
||||
private authService = inject(AuthService);
|
||||
private ngZone = inject(NgZone);
|
||||
private stateService = inject(PluginStateService);
|
||||
private badgeService = inject(PluginBadgeService);
|
||||
private rolesService = inject(PluginRolesService);
|
||||
|
||||
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)
|
||||
private clientPluginMetadataMap = new Map<string, any>();
|
||||
private mercureEventSource: EventSource | null = null;
|
||||
public statusUpdates$ = this.stateService.statusUpdates$;
|
||||
|
||||
constructor() {
|
||||
this.refreshPlugins().subscribe();
|
||||
this.initMercureSubscription();
|
||||
this.stateService.initMercureSubscription();
|
||||
|
||||
// Re-aplicar traducciones dinámicas al cambiar de idioma
|
||||
this.translateService.onLangChange.subscribe((event) => {
|
||||
|
|
@ -150,158 +47,50 @@ export class PluginService {
|
|||
});
|
||||
}
|
||||
|
||||
private getStatusKey(pluginName: string, idOrUuid: string | number): string {
|
||||
return `${pluginName}:${idOrUuid}`;
|
||||
}
|
||||
|
||||
private getPartitionStatusKey(pluginName: string, idOrUuid: string | number, disk: number, partition: number): string {
|
||||
return `${pluginName}:${idOrUuid}:${disk}:${partition}`;
|
||||
}
|
||||
// --- Gestión de estado delegado a PluginStateService ---
|
||||
|
||||
public setPluginStatus(pluginName: string, idOrUuid: string | number, status: string): void {
|
||||
this.clientPluginStatusMap.set(this.getStatusKey(pluginName, idOrUuid), status);
|
||||
this.stateService.setPluginStatus(pluginName, idOrUuid, status);
|
||||
}
|
||||
|
||||
public getPluginStatus(pluginName: string, idOrUuid: string | number): string | undefined {
|
||||
return this.clientPluginStatusMap.get(this.getStatusKey(pluginName, idOrUuid));
|
||||
return this.stateService.getPluginStatus(pluginName, idOrUuid);
|
||||
}
|
||||
|
||||
public setPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number, status: string): void {
|
||||
this.clientPluginStatusMap.set(this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition), status);
|
||||
this.stateService.setPartitionPluginStatus(pluginName, idOrUuid, disk, partition, status);
|
||||
}
|
||||
|
||||
public getPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number): string | undefined {
|
||||
return this.clientPluginStatusMap.get(this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition));
|
||||
return this.stateService.getPartitionPluginStatus(pluginName, idOrUuid, disk, partition);
|
||||
}
|
||||
|
||||
public clearPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number): void {
|
||||
const key = this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition);
|
||||
this.clientPluginStatusMap.delete(key);
|
||||
this.clientPluginMetadataMap.delete(key);
|
||||
this.stateService.clearPartitionPluginStatus(pluginName, idOrUuid, disk, partition);
|
||||
}
|
||||
|
||||
public clearPluginStatus(pluginName: string, idOrUuid: string | number): void {
|
||||
const globalKey = this.getStatusKey(pluginName, idOrUuid);
|
||||
this.clientPluginStatusMap.delete(globalKey);
|
||||
this.clientPluginMetadataMap.delete(globalKey);
|
||||
|
||||
const prefix = `${pluginName}:${idOrUuid}:`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
this.stateService.clearPluginStatus(pluginName, idOrUuid);
|
||||
}
|
||||
|
||||
public setPluginMetadata(pluginName: string, idOrUuid: string | number, metadata: any, disk?: number, partition?: number): void {
|
||||
const key = (disk !== undefined && partition !== undefined)
|
||||
? this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)
|
||||
: this.getStatusKey(pluginName, idOrUuid);
|
||||
const existing = this.clientPluginMetadataMap.get(key) || {};
|
||||
this.clientPluginMetadataMap.set(key, { ...existing, ...metadata });
|
||||
this.stateService.setPluginMetadata(pluginName, idOrUuid, metadata, disk, partition);
|
||||
}
|
||||
|
||||
public getPluginMetadata(pluginName: string, idOrUuid: string | number, disk?: number, partition?: number): any {
|
||||
const key = (disk !== undefined && partition !== undefined)
|
||||
? this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)
|
||||
: this.getStatusKey(pluginName, idOrUuid);
|
||||
return this.clientPluginMetadataMap.get(key);
|
||||
return this.stateService.getPluginMetadata(pluginName, idOrUuid, disk, partition);
|
||||
}
|
||||
|
||||
public clearPluginMetadata(pluginName: string, idOrUuid: string | number, disk?: number, partition?: number): void {
|
||||
const key = (disk !== undefined && partition !== undefined)
|
||||
? this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)
|
||||
: this.getStatusKey(pluginName, idOrUuid);
|
||||
this.clientPluginMetadataMap.delete(key);
|
||||
this.stateService.clearPluginMetadata(pluginName, idOrUuid, disk, partition);
|
||||
}
|
||||
|
||||
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) => {
|
||||
this.ngZone.run(() => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data && data.plugin) {
|
||||
const entityId = data.clientId !== undefined && data.clientId !== null ? String(data.clientId) : null;
|
||||
const entityUuid = data.clientUuid ? String(data.clientUuid) : null;
|
||||
const rawDisk = data.disk ?? data.disk_number ?? data.diskNumber;
|
||||
const rawPart = data.partition ?? data.partition_number ?? data.partitionNumber;
|
||||
const hasPartition = rawDisk !== undefined && rawDisk !== null && rawPart !== undefined && rawPart !== null;
|
||||
const disk = hasPartition ? Number(rawDisk) : undefined;
|
||||
const partition = hasPartition ? Number(rawPart) : undefined;
|
||||
|
||||
// 1. Estado a nivel de partición
|
||||
if (hasPartition && disk !== undefined && partition !== undefined) {
|
||||
const partStatus = data.status;
|
||||
if (partStatus === 'NONE') {
|
||||
if (entityId) this.clearPartitionPluginStatus(data.plugin, entityId, disk, partition);
|
||||
if (entityUuid) this.clearPartitionPluginStatus(data.plugin, entityUuid, disk, partition);
|
||||
} else if (partStatus) {
|
||||
if (entityId) this.setPartitionPluginStatus(data.plugin, entityId, disk, partition, partStatus);
|
||||
if (entityUuid) this.setPartitionPluginStatus(data.plugin, entityUuid, disk, partition, partStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Estado general del cliente
|
||||
const generalStatus = data.clientStatus !== undefined
|
||||
? data.clientStatus
|
||||
: (data.client_status !== undefined ? data.client_status : (!hasPartition ? data.status : null));
|
||||
|
||||
if (generalStatus !== null) {
|
||||
if (generalStatus === 'NONE') {
|
||||
if (entityId) {
|
||||
this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, entityId));
|
||||
this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, entityId));
|
||||
}
|
||||
if (entityUuid) {
|
||||
this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, entityUuid));
|
||||
this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, entityUuid));
|
||||
}
|
||||
} else {
|
||||
if (entityId) this.setPluginStatus(data.plugin, entityId, generalStatus);
|
||||
if (entityUuid) this.setPluginStatus(data.plugin, entityUuid, generalStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadatos contextuales arbitrarios
|
||||
const metadata: any = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!['plugin', 'status', 'clientStatus', 'client_status', 'clientId', 'clientUuid', 'disk', 'disk_number', 'diskNumber', 'partition', 'partition_number', 'partitionNumber', 'action', 'timestamp', 'subtarget'].includes(k) && v !== null && v !== undefined) {
|
||||
metadata[k] = v;
|
||||
}
|
||||
}
|
||||
const hasMeta = Object.keys(metadata).length > 0;
|
||||
if (hasMeta) {
|
||||
if (entityId) this.setPluginMetadata(data.plugin, entityId, metadata, disk, partition);
|
||||
if (entityUuid) this.setPluginMetadata(data.plugin, entityUuid, metadata, disk, partition);
|
||||
}
|
||||
|
||||
this.statusUpdatesSubject.next({
|
||||
plugin: data.plugin,
|
||||
clientId: (entityId || entityUuid) ?? undefined,
|
||||
status: data.status,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
};
|
||||
} catch {
|
||||
}
|
||||
public loadActivePluginsStatus(): Observable<void> {
|
||||
return this.stateService.loadActivePluginsStatus(this.activePlugins);
|
||||
}
|
||||
|
||||
// --- Registro, ciclo de vida e i18n de Plugins ---
|
||||
|
||||
refreshPlugins(): Observable<PluginManifest[]> {
|
||||
const apiUrl = this.configService.apiUrl;
|
||||
if (!apiUrl) {
|
||||
|
|
@ -379,84 +168,6 @@ export class PluginService {
|
|||
}
|
||||
}
|
||||
|
||||
loadActivePluginsStatus(): Observable<void> {
|
||||
this.initMercureSubscription();
|
||||
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;
|
||||
const rawDisk = row.disk_number ?? row.diskNumber ?? row.disk;
|
||||
const rawPart = row.partition_number ?? row.partitionNumber ?? row.partition;
|
||||
const hasPartition = rawDisk !== undefined && rawDisk !== null && rawPart !== undefined && rawPart !== null;
|
||||
const disk = hasPartition ? Number(rawDisk) : undefined;
|
||||
const partition = hasPartition ? Number(rawPart) : undefined;
|
||||
const clientStatus = row.client_status ?? row.clientStatus ?? (!hasPartition ? status : null);
|
||||
|
||||
// Extraer metadatos arbitrarios (dni, remaining_minutes, etc.)
|
||||
const metadata: any = {};
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
if (!['status', 'client_status', 'clientStatus', 'client_id', 'clientId', 'id', 'client_uuid', 'clientUuid', 'uuid', 'disk_number', 'diskNumber', 'disk', 'partition_number', 'partitionNumber', 'partition', 'subtarget'].includes(k) && v !== null && v !== undefined) {
|
||||
metadata[k] = v;
|
||||
}
|
||||
}
|
||||
const hasMeta = Object.keys(metadata).length > 0;
|
||||
|
||||
const idStr = id !== undefined && id !== null ? String(id) : null;
|
||||
if (idStr) {
|
||||
if (hasPartition && disk !== undefined && partition !== undefined && status && status !== 'NONE') {
|
||||
this.setPartitionPluginStatus(plugin.name, idStr, disk, partition, status);
|
||||
}
|
||||
if (clientStatus && clientStatus !== 'NONE') {
|
||||
this.setPluginStatus(plugin.name, idStr, clientStatus);
|
||||
}
|
||||
if (hasMeta) {
|
||||
this.setPluginMetadata(plugin.name, idStr, metadata, disk, partition);
|
||||
}
|
||||
}
|
||||
if (uuid) {
|
||||
if (hasPartition && disk !== undefined && partition !== undefined && status && status !== 'NONE') {
|
||||
this.setPartitionPluginStatus(plugin.name, uuid, disk, partition, status);
|
||||
}
|
||||
if (clientStatus && clientStatus !== 'NONE') {
|
||||
this.setPluginStatus(plugin.name, uuid, clientStatus);
|
||||
}
|
||||
if (hasMeta) {
|
||||
this.setPluginMetadata(plugin.name, uuid, metadata, disk, partition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
tap(() => {
|
||||
this.statusUpdatesSubject.next({ plugin: 'all', status: 'REFRESHED' });
|
||||
}),
|
||||
map(() => undefined)
|
||||
);
|
||||
}
|
||||
|
||||
getPlugins(): PluginManifest[] {
|
||||
return this.activePlugins.filter(p => p.enabled);
|
||||
}
|
||||
|
|
@ -510,132 +221,6 @@ export class PluginService {
|
|||
.filter(group => group.commands.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si el usuario autenticado tiene permiso para ejecutar un comando específico.
|
||||
* Jerarquía: configData.command_roles[slug] > command.roles > público (todos los roles)
|
||||
*/
|
||||
isCommandAllowedForUser(command: PluginCommand, pluginName: string): boolean {
|
||||
if (this.authService.isSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const config = this.getPluginConfig(pluginName);
|
||||
const commandRolesConfig = config?.['command_roles'] || {};
|
||||
let allowedRoles: string[] | undefined = commandRolesConfig[command.slug];
|
||||
|
||||
if (!allowedRoles || !Array.isArray(allowedRoles)) {
|
||||
allowedRoles = command.roles;
|
||||
}
|
||||
|
||||
// Si ni en configuración ni en el manifiesto se especifican roles, la función es pública
|
||||
if (!allowedRoles || allowedRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userRoles = (this.authService.roles || []).map(r => r.toUpperCase());
|
||||
const currentCategory = (this.authService.getCurrentRole() || '').toLowerCase();
|
||||
|
||||
return allowedRoles.some(role => {
|
||||
const rUpper = role.toUpperCase();
|
||||
const rLower = role.toLowerCase();
|
||||
|
||||
return userRoles.includes(rUpper) ||
|
||||
rLower === currentCategory ||
|
||||
(rUpper === 'ROLE_SUPER_ADMIN' && this.authService.isSuperAdmin) ||
|
||||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_ADMIN' && this.authService.isOUAdmin) ||
|
||||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR' && this.authService.isOUOperator) ||
|
||||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL' && this.authService.isOUMinimal);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la lista de roles/grupos disponibles en el sistema consultando a ogCore,
|
||||
* con fallback seguro a los roles canónicos de OpenGnSys.
|
||||
*/
|
||||
getUserRoles(): Observable<RoleDefinition[]> {
|
||||
const apiUrl = this.configService.apiUrl;
|
||||
const defaultRoles: RoleDefinition[] = [
|
||||
{ key: 'ROLE_SUPER_ADMIN', name: 'Super Admin', shortCode: 'super-admin' },
|
||||
{ key: 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', name: 'Admin de aulas', shortCode: 'ou-admin' },
|
||||
{ key: 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', name: 'Operador de aulas', shortCode: 'ou-operator' },
|
||||
{ key: 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL', name: 'Usuario básico de aulas', shortCode: 'ou-minimal' },
|
||||
];
|
||||
|
||||
if (!apiUrl) {
|
||||
return of(defaultRoles);
|
||||
}
|
||||
|
||||
return this.http.get<any>(`${apiUrl}/user-groups?page=1&itemsPerPage=1000`).pipe(
|
||||
map(res => {
|
||||
const groups = res?.['hydra:member'] || (Array.isArray(res) ? res : []);
|
||||
if (!Array.isArray(groups) || groups.length === 0) {
|
||||
return defaultRoles;
|
||||
}
|
||||
|
||||
const roles: RoleDefinition[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
// Descubrir roles desde los grupos registrados en la base de datos
|
||||
for (const g of groups) {
|
||||
const perms = Array.isArray(g.permissions) ? g.permissions : [];
|
||||
for (const perm of perms) {
|
||||
if (!seenKeys.has(perm)) {
|
||||
seenKeys.add(perm);
|
||||
const matchedDef = defaultRoles.find(dr => dr.key === perm);
|
||||
roles.push({
|
||||
key: perm,
|
||||
name: g.name || matchedDef?.name || perm,
|
||||
shortCode: matchedDef?.shortCode
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Garantizar que todos los roles canónicos estándar estén presentes
|
||||
for (const dr of defaultRoles) {
|
||||
if (!seenKeys.has(dr.key)) {
|
||||
roles.push(dr);
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
}),
|
||||
catchError(() => of(defaultRoles))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve los roles actualmente asignados a un comando del plugin.
|
||||
*/
|
||||
getCommandRoles(pluginName: string, commandSlug: string): string[] {
|
||||
const plugin = this.getPlugin(pluginName);
|
||||
const cmd = plugin?.commands?.find(c => c.slug === commandSlug);
|
||||
const config = this.getPluginConfig(pluginName);
|
||||
const commandRolesConfig = config?.['command_roles'] || {};
|
||||
|
||||
if (commandRolesConfig[commandSlug] && Array.isArray(commandRolesConfig[commandSlug])) {
|
||||
return [...commandRolesConfig[commandSlug]];
|
||||
}
|
||||
|
||||
if (cmd?.roles && Array.isArray(cmd.roles)) {
|
||||
return [...cmd.roles];
|
||||
}
|
||||
|
||||
return ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste la matriz de roles configurados para los comandos de un plugin.
|
||||
*/
|
||||
savePluginCommandRoles(pluginName: string, commandRoles: Record<string, string[]>): void {
|
||||
const currentConfig = this.getPluginConfig(pluginName);
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
command_roles: commandRoles
|
||||
};
|
||||
this.savePluginConfig(pluginName, updatedConfig);
|
||||
}
|
||||
|
||||
getCommands(): PluginCommand[] {
|
||||
const commands: PluginCommand[] = [];
|
||||
for (const group of this.getPluginGroups()) {
|
||||
|
|
@ -703,155 +288,41 @@ export class PluginService {
|
|||
return true;
|
||||
}
|
||||
|
||||
// --- Roles y permisos delegados a PluginRolesService ---
|
||||
|
||||
isCommandAllowedForUser(command: PluginCommand, pluginName: string): boolean {
|
||||
return this.rolesService.isCommandAllowedForUser(command, this.getPluginConfig(pluginName));
|
||||
}
|
||||
|
||||
getUserRoles(): Observable<RoleDefinition[]> {
|
||||
return this.rolesService.getUserRoles();
|
||||
}
|
||||
|
||||
getCommandRoles(pluginName: string, commandSlug: string): string[] {
|
||||
return this.rolesService.getCommandRoles(this.getPlugin(pluginName), commandSlug, this.getPluginConfig(pluginName));
|
||||
}
|
||||
|
||||
savePluginCommandRoles(pluginName: string, commandRoles: Record<string, string[]>): void {
|
||||
const currentConfig = this.getPluginConfig(pluginName);
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
command_roles: commandRoles
|
||||
};
|
||||
this.savePluginConfig(pluginName, updatedConfig);
|
||||
}
|
||||
|
||||
// --- Badges delegados a PluginBadgeService ---
|
||||
|
||||
getClientBadges(client: any): PluginBadge[] {
|
||||
if (!this.mercureEventSource) {
|
||||
this.initMercureSubscription();
|
||||
}
|
||||
const badges: PluginBadge[] = [];
|
||||
if (!client || this.activePlugins.length === 0) return badges;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Estado global del cliente definido directamente por el plugin
|
||||
const currentStatus = (idKey ? this.getPluginStatus(plugin.name, idKey) : null)
|
||||
|| (uuidKey ? this.getPluginStatus(plugin.name, uuidKey) : null);
|
||||
|
||||
if (!currentStatus || currentStatus === 'NONE') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Enriquecer tooltip con metadatos contextuales si el badge declara tooltipFields
|
||||
const meta = (idKey ? this.getPluginMetadata(plugin.name, idKey) : null)
|
||||
|| (uuidKey ? this.getPluginMetadata(plugin.name, uuidKey) : null);
|
||||
|
||||
if (matchingBadge.tooltipFields && matchingBadge.tooltipFields.length > 0 && meta) {
|
||||
const infoParts: string[] = [];
|
||||
for (const field of matchingBadge.tooltipFields) {
|
||||
const val = meta[field.key];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
const prefix = field.prefix || '';
|
||||
const suffix = field.suffix || '';
|
||||
infoParts.push(`${prefix}${val}${suffix}`);
|
||||
}
|
||||
}
|
||||
if (infoParts.length > 0) {
|
||||
const detailStr = infoParts.join(' · ');
|
||||
tooltip = tooltip ? `${tooltip} (${detailStr})` : detailStr;
|
||||
}
|
||||
}
|
||||
|
||||
badges.push({
|
||||
id: plugin.name,
|
||||
type: matchingBadge.type,
|
||||
icon: matchingBadge.icon,
|
||||
label,
|
||||
tooltip
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
return this.badgeService.getClientBadges(this.activePlugins, client);
|
||||
}
|
||||
|
||||
getPartitionBadges(client: any, diskNumber: number, partitionNumber: number): PluginBadge[] {
|
||||
const badges: PluginBadge[] = [];
|
||||
if (!client || this.activePlugins.length === 0) return badges;
|
||||
|
||||
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.getPartitionPluginStatus(plugin.name, idKey, diskNumber, partitionNumber) : null)
|
||||
|| (uuidKey ? this.getPartitionPluginStatus(plugin.name, uuidKey, diskNumber, partitionNumber) : null);
|
||||
|
||||
if (!currentStatus || currentStatus === 'NONE') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const meta = (idKey ? this.getPluginMetadata(plugin.name, idKey, diskNumber, partitionNumber) : null)
|
||||
|| (uuidKey ? this.getPluginMetadata(plugin.name, uuidKey, diskNumber, partitionNumber) : null);
|
||||
|
||||
if (matchingBadge.tooltipFields && matchingBadge.tooltipFields.length > 0 && meta) {
|
||||
const infoParts: string[] = [];
|
||||
for (const field of matchingBadge.tooltipFields) {
|
||||
const val = meta[field.key];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
const prefix = field.prefix || '';
|
||||
const suffix = field.suffix || '';
|
||||
infoParts.push(`${prefix}${val}${suffix}`);
|
||||
}
|
||||
}
|
||||
if (infoParts.length > 0) {
|
||||
const detailStr = infoParts.join(' · ');
|
||||
tooltip = tooltip ? `${tooltip} (${detailStr})` : detailStr;
|
||||
}
|
||||
}
|
||||
|
||||
badges.push({
|
||||
id: plugin.name,
|
||||
type: matchingBadge.type,
|
||||
icon: matchingBadge.icon,
|
||||
label,
|
||||
tooltip
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
return this.badgeService.getPartitionBadges(this.activePlugins, client, diskNumber, partitionNumber);
|
||||
}
|
||||
|
||||
// --- Configuración de Plugins (Local + Backend) ---
|
||||
|
||||
getPluginConfig(pluginName: string): Record<string, any> {
|
||||
const plugin = this.getPlugin(pluginName);
|
||||
const defaults = plugin?.defaultConfig || {};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PluginStateService } from './plugin-state.service';
|
||||
import { PluginManifest, PluginBadge } from './plugin.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PluginBadgeService {
|
||||
private translateService = inject(TranslateService);
|
||||
private pluginState = inject(PluginStateService);
|
||||
|
||||
public getClientBadges(activePlugins: PluginManifest[], client: any): PluginBadge[] {
|
||||
if (!this.pluginState.isMercureConnected()) {
|
||||
this.pluginState.initMercureSubscription();
|
||||
}
|
||||
const badges: PluginBadge[] = [];
|
||||
if (!client || !activePlugins || activePlugins.length === 0) return badges;
|
||||
|
||||
const idKey = client.id ? String(client.id) : null;
|
||||
const uuidKey = client.uuid || client.name;
|
||||
|
||||
for (const plugin of activePlugins) {
|
||||
if (!plugin.badges || plugin.badges.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Estado global del cliente definido directamente por el plugin
|
||||
const currentStatus = (idKey ? this.pluginState.getPluginStatus(plugin.name, idKey) : null)
|
||||
|| (uuidKey ? this.pluginState.getPluginStatus(plugin.name, uuidKey) : null);
|
||||
|
||||
const meta = (idKey ? this.pluginState.getPluginMetadata(plugin.name, idKey) : null)
|
||||
|| (uuidKey ? this.pluginState.getPluginMetadata(plugin.name, uuidKey) : null);
|
||||
|
||||
const badge = this.buildBadge(plugin, currentStatus, meta);
|
||||
if (badge) {
|
||||
badges.push(badge);
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
public getPartitionBadges(activePlugins: PluginManifest[], client: any, diskNumber: number, partitionNumber: number): PluginBadge[] {
|
||||
const badges: PluginBadge[] = [];
|
||||
if (!client || !activePlugins || activePlugins.length === 0) return badges;
|
||||
|
||||
const idKey = client.id ? String(client.id) : null;
|
||||
const uuidKey = client.uuid || client.name;
|
||||
|
||||
for (const plugin of activePlugins) {
|
||||
if (!plugin.badges || plugin.badges.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentStatus = (idKey ? this.pluginState.getPartitionPluginStatus(plugin.name, idKey, diskNumber, partitionNumber) : null)
|
||||
|| (uuidKey ? this.pluginState.getPartitionPluginStatus(plugin.name, uuidKey, diskNumber, partitionNumber) : null);
|
||||
|
||||
const meta = (idKey ? this.pluginState.getPluginMetadata(plugin.name, idKey, diskNumber, partitionNumber) : null)
|
||||
|| (uuidKey ? this.pluginState.getPluginMetadata(plugin.name, uuidKey, diskNumber, partitionNumber) : null);
|
||||
|
||||
const badge = this.buildBadge(plugin, currentStatus, meta);
|
||||
if (badge) {
|
||||
badges.push(badge);
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
private buildBadge(plugin: PluginManifest, currentStatus: string | null | undefined, meta: any): PluginBadge | null {
|
||||
if (!currentStatus || currentStatus === 'NONE' || !plugin.badges || plugin.badges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingBadge = plugin.badges.find(
|
||||
b => b.status.toUpperCase() === currentStatus.toUpperCase()
|
||||
);
|
||||
|
||||
if (!matchingBadge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Enriquecer tooltip con metadatos contextuales si el badge declara tooltipFields
|
||||
if (matchingBadge.tooltipFields && matchingBadge.tooltipFields.length > 0 && meta) {
|
||||
const infoParts: string[] = [];
|
||||
for (const field of matchingBadge.tooltipFields) {
|
||||
const val = meta[field.key];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
const prefix = field.prefix || '';
|
||||
const suffix = field.suffix || '';
|
||||
infoParts.push(`${prefix}${val}${suffix}`);
|
||||
}
|
||||
}
|
||||
if (infoParts.length > 0) {
|
||||
const detailStr = infoParts.join(' · ');
|
||||
tooltip = tooltip ? `${tooltip} (${detailStr})` : detailStr;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: plugin.name,
|
||||
type: matchingBadge.type,
|
||||
icon: matchingBadge.icon,
|
||||
label,
|
||||
tooltip
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { AuthService } from '@services/auth.service';
|
||||
import { ConfigService } from '@services/config.service';
|
||||
import { PluginCommand, RoleDefinition, PluginManifest } from './plugin.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PluginRolesService {
|
||||
private http = inject(HttpClient);
|
||||
private authService = inject(AuthService);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
/**
|
||||
* Comprueba si el usuario autenticado tiene permiso para ejecutar un comando específico.
|
||||
* Jerarquía: config.command_roles[slug] > command.roles > público (todos los roles)
|
||||
*/
|
||||
public isCommandAllowedForUser(command: PluginCommand, config?: Record<string, any>): boolean {
|
||||
if (this.authService.isSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const commandRolesConfig = config?.['command_roles'] || {};
|
||||
let allowedRoles: string[] | undefined = commandRolesConfig[command.slug];
|
||||
|
||||
if (!allowedRoles || !Array.isArray(allowedRoles)) {
|
||||
allowedRoles = command.roles;
|
||||
}
|
||||
|
||||
// Si ni en configuración ni en el manifiesto se especifican roles, la función es pública
|
||||
if (!allowedRoles || allowedRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userRoles = (this.authService.roles || []).map(r => r.toUpperCase());
|
||||
const currentCategory = (this.authService.getCurrentRole() || '').toLowerCase();
|
||||
|
||||
return allowedRoles.some(role => {
|
||||
const rUpper = role.toUpperCase();
|
||||
const rLower = role.toLowerCase();
|
||||
|
||||
return userRoles.includes(rUpper) ||
|
||||
rLower === currentCategory ||
|
||||
(rUpper === 'ROLE_SUPER_ADMIN' && this.authService.isSuperAdmin) ||
|
||||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_ADMIN' && this.authService.isOUAdmin) ||
|
||||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR' && this.authService.isOUOperator) ||
|
||||
(rUpper === 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL' && this.authService.isOUMinimal);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la lista de roles/grupos disponibles en el sistema consultando a ogCore,
|
||||
* con fallback seguro a los roles canónicos de OpenGnSys.
|
||||
*/
|
||||
public getUserRoles(): Observable<RoleDefinition[]> {
|
||||
const apiUrl = this.configService.apiUrl;
|
||||
const defaultRoles: RoleDefinition[] = [
|
||||
{ key: 'ROLE_SUPER_ADMIN', name: 'Super Admin', shortCode: 'super-admin' },
|
||||
{ key: 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', name: 'Admin de aulas', shortCode: 'ou-admin' },
|
||||
{ key: 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', name: 'Operador de aulas', shortCode: 'ou-operator' },
|
||||
{ key: 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL', name: 'Usuario básico de aulas', shortCode: 'ou-minimal' },
|
||||
];
|
||||
|
||||
if (!apiUrl) {
|
||||
return of(defaultRoles);
|
||||
}
|
||||
|
||||
return this.http.get<any>(`${apiUrl}/user-groups?page=1&itemsPerPage=1000`).pipe(
|
||||
map(res => {
|
||||
const groups = res?.['hydra:member'] || (Array.isArray(res) ? res : []);
|
||||
if (!Array.isArray(groups) || groups.length === 0) {
|
||||
return defaultRoles;
|
||||
}
|
||||
|
||||
const roles: RoleDefinition[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
// Descubrir roles desde los grupos registrados en la base de datos
|
||||
for (const g of groups) {
|
||||
const perms = Array.isArray(g.permissions) ? g.permissions : [];
|
||||
for (const perm of perms) {
|
||||
if (!seenKeys.has(perm)) {
|
||||
seenKeys.add(perm);
|
||||
const matchedDef = defaultRoles.find(dr => dr.key === perm);
|
||||
roles.push({
|
||||
key: perm,
|
||||
name: g.name || matchedDef?.name || perm,
|
||||
shortCode: matchedDef?.shortCode
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Garantizar que todos los roles canónicos estándar estén presentes
|
||||
for (const dr of defaultRoles) {
|
||||
if (!seenKeys.has(dr.key)) {
|
||||
roles.push(dr);
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
}),
|
||||
catchError(() => of(defaultRoles))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve los roles actualmente asignados a un comando del plugin.
|
||||
*/
|
||||
public getCommandRoles(plugin: PluginManifest | undefined, commandSlug: string, config?: Record<string, any>): string[] {
|
||||
const cmd = plugin?.commands?.find(c => c.slug === commandSlug);
|
||||
const commandRolesConfig = config?.['command_roles'] || {};
|
||||
|
||||
if (commandRolesConfig[commandSlug] && Array.isArray(commandRolesConfig[commandSlug])) {
|
||||
return [...commandRolesConfig[commandSlug]];
|
||||
}
|
||||
|
||||
if (cmd?.roles && Array.isArray(cmd.roles)) {
|
||||
return [...cmd.roles];
|
||||
}
|
||||
|
||||
return ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import { inject, Injectable, NgZone } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Subject, Observable, of, forkJoin } from 'rxjs';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { ConfigService } from '@services/config.service';
|
||||
import { PluginManifest, PluginStatusUpdate } from './plugin.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PluginStateService {
|
||||
private http = inject(HttpClient);
|
||||
private configService = inject(ConfigService);
|
||||
private ngZone = inject(NgZone);
|
||||
|
||||
private statusUpdatesSubject = new Subject<PluginStatusUpdate>();
|
||||
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)
|
||||
private clientPluginMetadataMap = new Map<string, any>();
|
||||
private mercureEventSource: EventSource | null = null;
|
||||
|
||||
private getStatusKey(pluginName: string, idOrUuid: string | number): string {
|
||||
return `${pluginName}:${idOrUuid}`;
|
||||
}
|
||||
|
||||
private getPartitionStatusKey(pluginName: string, idOrUuid: string | number, disk: number, partition: number): string {
|
||||
return `${pluginName}:${idOrUuid}:${disk}:${partition}`;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public setPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number, status: string): void {
|
||||
this.clientPluginStatusMap.set(this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition), status);
|
||||
}
|
||||
|
||||
public getPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number): string | undefined {
|
||||
return this.clientPluginStatusMap.get(this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition));
|
||||
}
|
||||
|
||||
public clearPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number): void {
|
||||
const key = this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition);
|
||||
this.clientPluginStatusMap.delete(key);
|
||||
this.clientPluginMetadataMap.delete(key);
|
||||
}
|
||||
|
||||
public clearPluginStatus(pluginName: string, idOrUuid: string | number): void {
|
||||
const globalKey = this.getStatusKey(pluginName, idOrUuid);
|
||||
this.clientPluginStatusMap.delete(globalKey);
|
||||
this.clientPluginMetadataMap.delete(globalKey);
|
||||
|
||||
const prefix = `${pluginName}:${idOrUuid}:`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setPluginMetadata(pluginName: string, idOrUuid: string | number, metadata: any, disk?: number, partition?: number): void {
|
||||
const key = (disk !== undefined && partition !== undefined)
|
||||
? this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)
|
||||
: this.getStatusKey(pluginName, idOrUuid);
|
||||
const existing = this.clientPluginMetadataMap.get(key) || {};
|
||||
this.clientPluginMetadataMap.set(key, { ...existing, ...metadata });
|
||||
}
|
||||
|
||||
public getPluginMetadata(pluginName: string, idOrUuid: string | number, disk?: number, partition?: number): any {
|
||||
const key = (disk !== undefined && partition !== undefined)
|
||||
? this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)
|
||||
: this.getStatusKey(pluginName, idOrUuid);
|
||||
return this.clientPluginMetadataMap.get(key);
|
||||
}
|
||||
|
||||
public clearPluginMetadata(pluginName: string, idOrUuid: string | number, disk?: number, partition?: number): void {
|
||||
const key = (disk !== undefined && partition !== undefined)
|
||||
? this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)
|
||||
: this.getStatusKey(pluginName, idOrUuid);
|
||||
this.clientPluginMetadataMap.delete(key);
|
||||
}
|
||||
|
||||
public isMercureConnected(): boolean {
|
||||
return this.mercureEventSource !== null;
|
||||
}
|
||||
|
||||
public 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) => {
|
||||
this.ngZone.run(() => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data && data.plugin) {
|
||||
const entityId = data.clientId !== undefined && data.clientId !== null ? String(data.clientId) : null;
|
||||
const entityUuid = data.clientUuid ? String(data.clientUuid) : null;
|
||||
const rawDisk = data.disk ?? data.disk_number ?? data.diskNumber;
|
||||
const rawPart = data.partition ?? data.partition_number ?? data.partitionNumber;
|
||||
const hasPartition = rawDisk !== undefined && rawDisk !== null && rawPart !== undefined && rawPart !== null;
|
||||
const disk = hasPartition ? Number(rawDisk) : undefined;
|
||||
const partition = hasPartition ? Number(rawPart) : undefined;
|
||||
|
||||
// 1. Estado a nivel de partición
|
||||
if (hasPartition && disk !== undefined && partition !== undefined) {
|
||||
const partStatus = data.status;
|
||||
if (partStatus === 'NONE') {
|
||||
if (entityId) this.clearPartitionPluginStatus(data.plugin, entityId, disk, partition);
|
||||
if (entityUuid) this.clearPartitionPluginStatus(data.plugin, entityUuid, disk, partition);
|
||||
} else if (partStatus) {
|
||||
if (entityId) this.setPartitionPluginStatus(data.plugin, entityId, disk, partition, partStatus);
|
||||
if (entityUuid) this.setPartitionPluginStatus(data.plugin, entityUuid, disk, partition, partStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Estado general del cliente
|
||||
const generalStatus = data.clientStatus !== undefined
|
||||
? data.clientStatus
|
||||
: (data.client_status !== undefined ? data.client_status : (!hasPartition ? data.status : null));
|
||||
|
||||
if (generalStatus !== null) {
|
||||
if (generalStatus === 'NONE') {
|
||||
if (entityId) {
|
||||
this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, entityId));
|
||||
this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, entityId));
|
||||
}
|
||||
if (entityUuid) {
|
||||
this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, entityUuid));
|
||||
this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, entityUuid));
|
||||
}
|
||||
} else {
|
||||
if (entityId) this.setPluginStatus(data.plugin, entityId, generalStatus);
|
||||
if (entityUuid) this.setPluginStatus(data.plugin, entityUuid, generalStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadatos contextuales arbitrarios
|
||||
const metadata: any = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!['plugin', 'status', 'clientStatus', 'client_status', 'clientId', 'clientUuid', 'disk', 'disk_number', 'diskNumber', 'partition', 'partition_number', 'partitionNumber', 'action', 'timestamp', 'subtarget'].includes(k) && v !== null && v !== undefined) {
|
||||
metadata[k] = v;
|
||||
}
|
||||
}
|
||||
const hasMeta = Object.keys(metadata).length > 0;
|
||||
if (hasMeta) {
|
||||
if (entityId) this.setPluginMetadata(data.plugin, entityId, metadata, disk, partition);
|
||||
if (entityUuid) this.setPluginMetadata(data.plugin, entityUuid, metadata, disk, partition);
|
||||
}
|
||||
|
||||
this.statusUpdatesSubject.next({
|
||||
plugin: data.plugin,
|
||||
clientId: (entityId || entityUuid) ?? undefined,
|
||||
status: data.status,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
};
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
public loadActivePluginsStatus(activePlugins: PluginManifest[]): Observable<void> {
|
||||
this.initMercureSubscription();
|
||||
const apiUrl = this.configService.apiUrl;
|
||||
if (!apiUrl || !activePlugins || activePlugins.length === 0) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
const pluginsWithStatus = 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;
|
||||
const rawDisk = row.disk_number ?? row.diskNumber ?? row.disk;
|
||||
const rawPart = row.partition_number ?? row.partitionNumber ?? row.partition;
|
||||
const hasPartition = rawDisk !== undefined && rawDisk !== null && rawPart !== undefined && rawPart !== null;
|
||||
const disk = hasPartition ? Number(rawDisk) : undefined;
|
||||
const partition = hasPartition ? Number(rawPart) : undefined;
|
||||
const clientStatus = row.client_status ?? row.clientStatus ?? (!hasPartition ? status : null);
|
||||
|
||||
// Extraer metadatos arbitrarios (dni, remaining_minutes, etc.)
|
||||
const metadata: any = {};
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
if (!['status', 'client_status', 'clientStatus', 'client_id', 'clientId', 'id', 'client_uuid', 'clientUuid', 'uuid', 'disk_number', 'diskNumber', 'disk', 'partition_number', 'partitionNumber', 'partition', 'subtarget'].includes(k) && v !== null && v !== undefined) {
|
||||
metadata[k] = v;
|
||||
}
|
||||
}
|
||||
const hasMeta = Object.keys(metadata).length > 0;
|
||||
|
||||
const idStr = id !== undefined && id !== null ? String(id) : null;
|
||||
if (idStr) {
|
||||
if (hasPartition && disk !== undefined && partition !== undefined && status && status !== 'NONE') {
|
||||
this.setPartitionPluginStatus(plugin.name, idStr, disk, partition, status);
|
||||
}
|
||||
if (clientStatus && clientStatus !== 'NONE') {
|
||||
this.setPluginStatus(plugin.name, idStr, clientStatus);
|
||||
}
|
||||
if (hasMeta) {
|
||||
this.setPluginMetadata(plugin.name, idStr, metadata, disk, partition);
|
||||
}
|
||||
}
|
||||
if (uuid) {
|
||||
if (hasPartition && disk !== undefined && partition !== undefined && status && status !== 'NONE') {
|
||||
this.setPartitionPluginStatus(plugin.name, uuid, disk, partition, status);
|
||||
}
|
||||
if (clientStatus && clientStatus !== 'NONE') {
|
||||
this.setPluginStatus(plugin.name, uuid, clientStatus);
|
||||
}
|
||||
if (hasMeta) {
|
||||
this.setPluginMetadata(plugin.name, uuid, metadata, disk, partition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
tap(() => {
|
||||
this.statusUpdatesSubject.next({ plugin: 'all', status: 'REFRESHED' });
|
||||
}),
|
||||
map(() => undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
export interface CommandParam {
|
||||
key: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
type: 'text' | 'number' | 'select' | 'checkbox';
|
||||
options?: string[];
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginCommand {
|
||||
slug: string;
|
||||
translationKey?: string;
|
||||
labelKey?: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
disabled: boolean;
|
||||
endpoint?: string;
|
||||
os?: string[]; // Ej: ['windows'], ['linux']
|
||||
params?: CommandParam[];
|
||||
confirm?: string;
|
||||
confirmKey?: string;
|
||||
roles?: string[]; // Roles autorizados por defecto (ej. ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN'])
|
||||
requireStatus?: string[]; // Estados requeridos del plugin para habilitar el comando
|
||||
excludeStatus?: string[]; // Estados del plugin que deshabilitan el comando
|
||||
}
|
||||
|
||||
export interface RoleDefinition {
|
||||
key: string; // Clave del rol Symfony (ej: 'ROLE_SUPER_ADMIN')
|
||||
name: string; // Nombre descriptivo (ej: 'Super Admin')
|
||||
shortCode?: string;// Código corto usado en frontend (ej: 'super-admin')
|
||||
}
|
||||
|
||||
export interface PluginBadgeField {
|
||||
key: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface PluginBadgeRule {
|
||||
status: string;
|
||||
type: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
tooltip: string;
|
||||
tooltipKey?: string;
|
||||
tooltipFields?: PluginBadgeField[];
|
||||
}
|
||||
|
||||
export interface PluginBadge {
|
||||
id: string; // Plugin name
|
||||
type: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
export interface FieldAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export interface SchemaField {
|
||||
key: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
description?: string;
|
||||
descriptionKey?: string;
|
||||
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea';
|
||||
language?: string;
|
||||
options?: string[];
|
||||
default: any;
|
||||
action?: FieldAction;
|
||||
}
|
||||
|
||||
export interface RecordColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
recordsEndpoint?: string;
|
||||
recordsColumns?: RecordColumn[];
|
||||
badges?: PluginBadgeRule[];
|
||||
i18n?: Record<string, Record<string, any>>;
|
||||
schema: SchemaField[];
|
||||
defaultConfig?: Record<string, any>;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PluginGroup {
|
||||
plugin: PluginManifest;
|
||||
commands: PluginCommand[];
|
||||
}
|
||||
|
||||
export interface PluginStatusUpdate {
|
||||
plugin: string;
|
||||
clientId?: string;
|
||||
status: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue