refactor(plugin): modularize PluginService into specialized sub-services and eliminate badge duplication
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details

main 1.25.1
Juan Manuel Bardallo 2026-11-01 20:49:45 +01:00
parent bfc7e07e2a
commit a5e565aced
7 changed files with 706 additions and 593 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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