feat(plugins): implement dynamic plugin architecture and bump version to 1.16.0

main
Juan Manuel Bardallo 2026-10-22 09:41:51 +02:00
parent 5576757c7b
commit 056c89ea53
20 changed files with 897 additions and 21 deletions

View File

@ -1,5 +1,16 @@
# Changelog
## [1.16.0] - 2026-10-22
### Added
- **Soporte Dinámico para Plugins (`PluginService`, `PluginsComponent`, `PluginSettingsComponent`)**:
- Incorporada infraestructura agnóstica para descubrimiento y gestión de plugins desacoplados a través de `/api/plugins`.
- Inyección dinámica de comandos personalizados en la consola de comandos (`execute-command`) según los plugins instalados.
- Generador dinámico de formularios de configuración basados en esquemas JSON (`app-plugin-settings`) con soporte para textos, números, selectores, casillas y scripts.
- Soporte de insignias de estado (badges) contextuales por cliente en las vistas de mosaico y lista de equipos (`groups.component`).
- Nuevo elemento condicional "Plugins" en el menú lateral (`sidebar`), visible únicamente cuando existen plugins activos en el sistema.
- Comportamiento 100% neutro e idéntico a 1.15.0 si no hay plugins instalados.
## [1.15.0] - 2026-10-20
### Added

View File

@ -48,6 +48,7 @@ import { HardwareComponent } from './components/hardware/hardware.component';
import { HardwareProfileComponent } from './components/hardware-profile/hardware-profile.component';
import { HardwareTypeComponent } from './components/hardware-type/hardware-type.component';
import { BootServerDashboardComponent } from './components/ogboot/boot-server-dashboard/boot-server-dashboard.component';
import { PluginsComponent } from './components/plugins/plugins.component';
const routes: Routes = [
{ path: '', redirectTo: 'auth/login', pathMatch: 'full' },
{
@ -84,6 +85,7 @@ const routes: Routes = [
{ path: 'hardware', component: HardwareComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'hardware-profiles', component: HardwareProfileComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'hardware-types', component: HardwareTypeComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'plugins', component: PluginsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
],
},
{

View File

@ -148,6 +148,8 @@ import { CreateSubnetComponent } from "./components/ogdhcp/create-subnet/create-
import { AddClientsToSubnetComponent } from "./components/ogdhcp/add-clients-to-subnet/add-clients-to-subnet.component";
import { ShowClientsComponent } from './components/ogdhcp/show-clients/show-clients.component';
import { OperationResultDialogComponent } from './components/ogdhcp/operation-result-dialog/operation-result-dialog.component';
import { PluginsComponent } from './components/plugins/plugins.component';
import { PluginSettingsComponent } from './components/plugins/plugin-settings/plugin-settings.component';
import { ManageClientComponent } from './components/groups/shared/clients/manage-client/manage-client.component';
import { ConvertImageComponent } from './components/repositories/convert-image/convert-image.component';
import { DhcpServersComponent } from './components/ogdhcp/dhcp-servers/dhcp-servers.component';
@ -353,7 +355,9 @@ registerLocaleData(localeEs, 'es-ES');
StatusLogsComponent,
ImportLegacyComponent,
PageNotFoundComponent,
OuSelectComponent
OuSelectComponent,
PluginsComponent,
PluginSettingsComponent
],
bootstrap: [AppComponent],
imports: [BrowserModule,

View File

@ -21,7 +21,10 @@
@for (command of arrayCommands; track command.slug) {
<button mat-menu-item [disabled]="command.disabled || (command.slug === 'create-image' && clientData.length > 1)"
(click)="onCommandSelect(command.slug)">
{{ command.translationKey | translate }}
@if (command.icon) {
<mat-icon>{{ command.icon }}</mat-icon>
}
<span>{{ command.translationKey ? (command.translationKey | translate) : (command.label || command.slug) }}</span>
</button>
}
</mat-menu>

View File

@ -12,6 +12,7 @@ import { AuthService } from '@services/auth.service';
import {SoftwareProfilePartitionComponent} from "./software-profile-partition/software-profile-partition.component";
import { QueueConfirmationModalComponent } from '../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { ExecuteTaskNowComponent } from './execute-task-now/execute-task-now.component';
import { PluginService } from '@services/plugin.service';
@Component({
selector: 'app-execute-command',
@ -59,6 +60,7 @@ export class ExecuteCommandComponent implements OnInit {
private toastService: ToastrService,
public auth: AuthService,
private dialog: MatDialog,
public pluginService: PluginService,
) {
this.baseUrl = this.configService.apiUrl;
}
@ -68,6 +70,10 @@ export class ExecuteCommandComponent implements OnInit {
const allowed = this.getAllowedCommandsByRole();
this.arrayCommands = this.arrayCommands.filter(c => allowed.includes(c.slug));
// Punto de anclaje para comandos aportados por plugins
const pluginCmds = this.pluginService.getCommands();
this.arrayCommands = [...this.arrayCommands, ...pluginCmds];
this.updateCommandStates();
}
@ -201,6 +207,21 @@ export class ExecuteCommandComponent implements OnInit {
}
private executeAction(action: any): void {
// Punto de anclaje para ejecución de comandos aportados por plugins
const pluginCmd = this.pluginService.findCommand(action);
if (pluginCmd) {
if (action === 'ogfreeze-freeze') {
this.pluginService.toggleFreezeStatus(this.clientData, true);
this.toastService.success('Comando de congelación (UWF) enviado correctamente a los equipos seleccionados', 'Plugin Freeze');
} else if (action === 'ogfreeze-unfreeze') {
this.pluginService.toggleFreezeStatus(this.clientData, false);
this.toastService.info('Comando de descongelación (UWF) enviado correctamente a los equipos seleccionados', 'Plugin Freeze');
} else {
this.toastService.success(`Acción ${pluginCmd.label} ejecutada en los equipos seleccionados`, 'Plugin');
}
return;
}
if (action === 'partition') {
this.openPartitionAssistant();
}

View File

@ -2051,4 +2051,52 @@ mat-button-toggle-group {
user-select: none;
}
.client-image-spacing {
margin-top: 0.5em;
}
.multi-image-container {
display: flex;
align-items: center;
gap: 4px;
}
.plugin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
width: fit-content;
}
.plugin-badge .plugin-badge-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
.plugin-badge.badge-frozen {
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
color: #0369a1;
border: 1px solid #7dd3fc;
}
.plugin-badge.badge-unfrozen {
background: #f1f5f9;
color: #64748b;
border: 1px solid #cbd5e1;
}
.plugin-badge.list-view-badge {
margin-top: 2px;
}

View File

@ -406,8 +406,8 @@
<mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(client)"
[checked]="selection.isSelected(client)">
</mat-checkbox>
<img style="margin-top: 0.5em;" [src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon" class="client-image" />
<img [src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon" class="client-image client-image-spacing" />
<div class="client-details">
<span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
<span class="client-ip">{{ client.ip }}</span>
@ -423,6 +423,11 @@
RemotePC
</span>
}
@for (badge of getPluginBadges(client); track badge.id) {
<span class="plugin-badge badge-{{badge.type}}" [matTooltip]="badge.tooltip">
<mat-icon class="plugin-badge-icon">{{ badge.icon }}</mat-icon> {{ badge.label }}
</span>
}
<div class="action-icons">
<app-execute-command [clientState]="client.status" [clientData]="[client]"
[buttonType]="'icon'" [icon]="'terminal'"
@ -549,6 +554,11 @@
RemotePC
</span>
}
@for (badge of getPluginBadges(client); track badge.id) {
<span class="plugin-badge badge-{{badge.type}} list-view-badge" [matTooltip]="badge.tooltip">
<mat-icon class="plugin-badge-icon">{{ badge.icon }}</mat-icon> {{ badge.label }}
</span>
}
</div>
</td>
</ng-container>
@ -586,7 +596,7 @@
</div>
} @else if (images.length > 1) {
<div class="cloning-cell">
<div style="display: flex; align-items: center; gap: 4px;">
<div class="multi-image-container">
<span class="image-name truncate-cell-medium" [matTooltip]="images[0].name">{{ images[0].name }}</span>
<span class="multi-image-badge" [matTooltip]="getMultiImageTooltip(images)">+{{images.length - 1}}</span>
</div>

View File

@ -36,6 +36,7 @@ import { ClientPendingTasksComponent } from '../task-logs/client-pending-tasks/c
import { ClientLogsModalComponent } from './shared/client-logs-modal/client-logs-modal.component';
import { ClientDiskLogsModalComponent } from './shared/client-disk-logs-modal/client-disk-logs-modal.component';
import { DeleteResultsModalComponent } from './shared/delete-results-modal/delete-results-modal.component';
import { PluginService, PluginBadge } from '@services/plugin.service';
enum NodeType {
OrganizationalUnit = 'organizational-unit',
@ -172,6 +173,10 @@ export class GroupsComponent implements OnInit, OnDestroy {
}).join('\n');
}
getPluginBadges(client: any): PluginBadge[] {
return this.pluginService.getClientBadges(client);
}
private subscriptions: Subscription = new Subscription();
constructor(
@ -186,6 +191,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
public auth: AuthService,
private configService: ConfigService,
private cd: ChangeDetectorRef,
public pluginService: PluginService,
) {
this.baseUrl = this.configService.apiUrl;
this.mercureUrl = this.configService.mercureUrl;

View File

@ -0,0 +1,225 @@
.plugin-settings-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 8px 0;
}
.plugin-header-card {
display: flex;
align-items: flex-start;
gap: 20px;
background: linear-gradient(135deg, rgba(25, 118, 210, 0.08) 0%, rgba(33, 150, 243, 0.03) 100%);
border: 1px solid rgba(25, 118, 210, 0.18);
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
}
.plugin-header-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: #1976d2;
color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 10px rgba(25, 118, 210, 0.3);
flex-shrink: 0;
}
.plugin-header-icon {
font-size: 32px;
width: 32px;
height: 32px;
}
.plugin-header-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.plugin-title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.plugin-title {
font-size: 1.35rem;
font-weight: 600;
margin: 0;
color: #1e293b;
}
.plugin-version-tag {
font-size: 0.75rem;
font-weight: 600;
background: #e2e8f0;
color: #475569;
padding: 2px 8px;
border-radius: 6px;
}
.plugin-status-tag {
font-size: 0.75rem;
font-weight: 600;
background: #dcfce7;
color: #166534;
padding: 2px 8px;
border-radius: 6px;
}
.plugin-description {
margin: 0;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.plugin-author {
font-size: 0.8rem;
color: #64748b;
margin-top: 4px;
}
.settings-form-wrapper {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.section-title-box {
display: flex;
align-items: center;
gap: 8px;
color: #0f172a;
}
.section-icon {
color: #1976d2;
}
.section-heading {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
}
.section-subheading {
margin: 6px 0 24px 0;
color: #64748b;
font-size: 0.9rem;
}
.form-fields-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.form-field-card {
display: flex;
flex-direction: column;
gap: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-field-card:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
}
.form-field-card.full-width {
grid-column: 1 / -1;
}
.field-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
font-weight: 600;
font-size: 0.92rem;
color: #1e293b;
}
.field-description {
font-size: 0.82rem;
color: #64748b;
line-height: 1.4;
}
.field-control-container {
margin-top: 8px;
}
.w-100 {
width: 100%;
}
.toggle-wrapper {
display: flex;
align-items: center;
height: 48px;
}
.code-textarea {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.88rem;
line-height: 1.45;
background: #0f172a;
color: #38bdf8;
padding: 12px;
border-radius: 6px;
}
.form-actions-bar {
display: flex;
justify-content: flex-end;
gap: 16px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
}
.btn-save, .btn-reset {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
padding: 0 20px;
}
.no-plugin-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #94a3b8;
gap: 12px;
}
.no-plugin-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
@media (max-width: 900px) {
.form-fields-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,97 @@
<div class="plugin-settings-container">
@if (plugin) {
<div class="plugin-header-card">
<div class="plugin-header-icon-box">
<mat-icon class="plugin-header-icon">{{ plugin.icon }}</mat-icon>
</div>
<div class="plugin-header-info">
<div class="plugin-title-row">
<h2 class="plugin-title">{{ plugin.title }}</h2>
<span class="plugin-version-tag">v{{ plugin.version }}</span>
<span class="plugin-status-tag">Activo</span>
</div>
<p class="plugin-description">{{ plugin.description }}</p>
<span class="plugin-author">Autor: {{ plugin.author }}</span>
</div>
</div>
<div class="settings-form-wrapper">
<div class="section-title-box">
<mat-icon class="section-icon">tune</mat-icon>
<h3 class="section-heading">Parámetros de Configuración del Plugin</h3>
</div>
<p class="section-subheading">
Ajusta el comportamiento y las opciones de ejecución. Los cambios se aplicarán inmediatamente a las nuevas órdenes enviadas a los puestos.
</p>
<div class="form-fields-grid">
@for (field of schema; track field.key) {
<div class="form-field-card" [class.full-width]="field.type === 'textarea'">
<div class="field-meta">
<label class="field-label" [for]="'field-' + field.key">{{ field.label }}</label>
@if (field.description) {
<span class="field-description">{{ field.description }}</span>
}
</div>
<div class="field-control-container">
@switch (field.type) {
@case ('select') {
<mat-form-field appearance="outline" class="w-100">
<mat-select [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
@for (opt of field.options; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('checkbox') {
<div class="toggle-wrapper">
<mat-slide-toggle [id]="'field-' + field.key" color="primary" [(ngModel)]="configData[field.key]">
{{ configData[field.key] ? 'Habilitado' : 'Deshabilitado' }}
</mat-slide-toggle>
</div>
}
@case ('number') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="number" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field>
}
@case ('text') {
<mat-form-field appearance="outline" class="w-100">
<input matInput type="text" [id]="'field-' + field.key" [(ngModel)]="configData[field.key]">
</mat-form-field>
}
@case ('textarea') {
<mat-form-field appearance="outline" class="w-100 code-field">
<textarea matInput rows="7" [id]="'field-' + field.key" class="code-textarea" [(ngModel)]="configData[field.key]"></textarea>
</mat-form-field>
}
}
</div>
</div>
}
</div>
<div class="form-actions-bar">
<button mat-stroked-button color="warn" (click)="reset()" class="btn-reset">
<mat-icon>restart_alt</mat-icon>
Restaurar por defecto
</button>
<button mat-raised-button color="primary" (click)="save()" class="btn-save">
<mat-icon>save</mat-icon>
Guardar Configuración
</button>
</div>
</div>
} @else {
<div class="no-plugin-box">
<mat-icon class="no-plugin-icon">info</mat-icon>
<p>Selecciona un plugin para ver y editar su configuración.</p>
</div>
}
</div>

View File

@ -0,0 +1,52 @@
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
import { PluginManifest, PluginService, SchemaField } from '@services/plugin.service';
import { ToastrService } from 'ngx-toastr';
@Component({
selector: 'app-plugin-settings',
templateUrl: './plugin-settings.component.html',
styleUrls: ['./plugin-settings.component.css']
})
export class PluginSettingsComponent implements OnInit, OnChanges {
@Input() pluginName: string = '';
plugin?: PluginManifest;
schema: SchemaField[] = [];
configData: Record<string, any> = {};
constructor(
private pluginService: PluginService,
private toast: ToastrService
) {}
ngOnInit(): void {
this.loadPluginData();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['pluginName']) {
this.loadPluginData();
}
}
private loadPluginData(): void {
if (!this.pluginName) return;
this.plugin = this.pluginService.getPlugin(this.pluginName);
if (this.plugin) {
this.schema = this.plugin.schema;
this.configData = this.pluginService.getPluginConfig(this.pluginName);
}
}
save(): void {
if (!this.pluginName) return;
this.pluginService.savePluginConfig(this.pluginName, this.configData);
this.toast.success('Configuración guardada correctamente', this.plugin?.title || 'Plugin');
}
reset(): void {
if (!this.pluginName) return;
this.configData = this.pluginService.resetPluginConfig(this.pluginName);
this.toast.info('Configuración restaurada a valores por defecto', this.plugin?.title || 'Plugin');
}
}

View File

@ -0,0 +1,117 @@
.plugins-page-container {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.plugins-page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.header-titles {
display: flex;
flex-direction: column;
gap: 6px;
}
.header-main-row {
display: flex;
align-items: center;
gap: 12px;
}
.header-title-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #1976d2;
}
.header-main-title {
font-size: 1.6rem;
font-weight: 700;
margin: 0;
color: #0f172a;
}
.plugins-count-badge {
background: #eff6ff;
color: #1d4ed8;
font-size: 0.8rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
border: 1px solid #bfdbfe;
}
.header-subtitle {
margin: 0;
color: #64748b;
font-size: 0.95rem;
max-width: 900px;
line-height: 1.5;
}
.plugins-content-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
overflow: hidden;
}
.tab-plugin-icon {
margin-right: 8px;
font-size: 20px;
width: 20px;
height: 20px;
}
.tab-inner-content {
padding: 24px;
}
.empty-plugins-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: #ffffff;
border: 2px dashed #cbd5e1;
border-radius: 16px;
padding: 60px 24px;
color: #64748b;
gap: 12px;
}
.empty-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: #94a3b8;
}
.empty-title {
font-size: 1.3rem;
font-weight: 600;
color: #334155;
margin: 0;
}
.empty-desc {
max-width: 550px;
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
}

View File

@ -0,0 +1,42 @@
<div class="plugins-page-container">
<div class="plugins-page-header">
<div class="header-titles">
<div class="header-main-row">
<mat-icon class="header-title-icon">extension</mat-icon>
<h1 class="header-main-title">Plugins y Extensiones</h1>
<span class="plugins-count-badge">{{ plugins.length }} Activo(s)</span>
</div>
<p class="header-subtitle">
Gestiona y personaliza los módulos y extensiones instalados en el servidor OpenGnsys. Cada plugin opera de manera desacoplada sin interferir con el núcleo del sistema.
</p>
</div>
</div>
@if (plugins.length > 0) {
<div class="plugins-content-card">
<mat-tab-group [(selectedIndex)]="selectedTabIndex" class="custom-plugin-tabs" animationDuration="200ms">
@for (plugin of plugins; track plugin.name) {
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-plugin-icon">{{ plugin.icon }}</mat-icon>
<span>{{ plugin.title }}</span>
</ng-template>
<div class="tab-inner-content">
<app-plugin-settings [pluginName]="plugin.name"></app-plugin-settings>
</div>
</mat-tab>
}
</mat-tab-group>
</div>
} @else {
<div class="empty-plugins-card">
<mat-icon class="empty-icon">extension_off</mat-icon>
<h3 class="empty-title">No hay plugins instalados</h3>
<p class="empty-desc">
Actualmente no hay ninguna extensión o plugin registrado en este servidor OpenGnsys.
Cuando instales un paquete compatible (como <code>og-plugin-freeze</code>), aparecerá automáticamente aquí.
</p>
</div>
}
</div>

View File

@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { PluginManifest, PluginService } from '@services/plugin.service';
@Component({
selector: 'app-plugins',
templateUrl: './plugins.component.html',
styleUrls: ['./plugins.component.css']
})
export class PluginsComponent implements OnInit {
plugins: PluginManifest[] = [];
selectedTabIndex: number = 0;
constructor(public pluginService: PluginService) {}
ngOnInit(): void {
this.plugins = this.pluginService.getPlugins();
}
}

View File

@ -149,20 +149,31 @@
</mat-nav-list>
<mat-list-item routerLink="/repositories" (click)="onItemClick()"
matTooltip="{{ 'TOOLTIP_REPOSITORIES' | translate }}" matTooltipShowDelay="1000"
*ngIf="auth.userCategory !== 'ou-operator' && auth.userCategory !== 'ou-minimal'">
<span class="entry">
<mat-icon class="icon">warehouse</mat-icon>
<span>{{ 'repositories' | translate }}</span>
</span>
</mat-list-item>
@if (auth.userCategory !== 'ou-operator' && auth.userCategory !== 'ou-minimal') {
<mat-list-item routerLink="/repositories" (click)="onItemClick()"
matTooltip="{{ 'TOOLTIP_REPOSITORIES' | translate }}" matTooltipShowDelay="1000">
<span class="entry">
<mat-icon class="icon">warehouse</mat-icon>
<span>{{ 'repositories' | translate }}</span>
</span>
</mat-list-item>
<mat-list-item routerLink="/menus" (click)="onItemClick()" matTooltip="{{ 'TOOLTIP_MENUS' | translate }}"
matTooltipShowDelay="1000" *ngIf="auth.userCategory !== 'ou-operator' && auth.userCategory !== 'ou-minimal'">
<span class="entry">
<mat-icon class="icon">list</mat-icon>
<span>{{ 'menus' | translate }}</span>
</span>
</mat-list-item>
<mat-list-item routerLink="/menus" (click)="onItemClick()" matTooltip="{{ 'TOOLTIP_MENUS' | translate }}"
matTooltipShowDelay="1000">
<span class="entry">
<mat-icon class="icon">list</mat-icon>
<span>{{ 'menus' | translate }}</span>
</span>
</mat-list-item>
@if (pluginService.hasPlugins()) {
<mat-list-item routerLink="/plugins" (click)="onItemClick()"
matTooltip="Plugins y Extensiones" matTooltipShowDelay="1000">
<span class="entry">
<mat-icon class="icon">extension</mat-icon>
<span>Plugins</span>
</span>
</mat-list-item>
}
}
</mat-nav-list>

View File

@ -1,6 +1,7 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AuthService } from '@services/auth.service';
import { PluginService } from '@services/plugin.service';
@Component({
selector: 'app-sidebar',
@ -42,7 +43,11 @@ export class SidebarComponent {
}
}
constructor(public dialog: MatDialog, public auth: AuthService,) {}
constructor(
public dialog: MatDialog,
public auth: AuthService,
public pluginService: PluginService
) {}
ngOnInit(): void {
this.username = this.auth.username

View File

@ -0,0 +1,195 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { ConfigService } from './config.service';
export interface PluginCommand {
slug: string;
translationKey?: string;
label: string;
icon: string;
disabled: boolean;
endpoint?: string;
}
export interface PluginBadge {
id: string;
type: 'frozen' | 'unfrozen' | 'info';
icon: string;
label: string;
tooltip: string;
}
export interface SchemaField {
key: string;
label: string;
description?: string;
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea';
options?: string[];
default: any;
}
export interface PluginManifest {
name: string;
title: string;
version: string;
description: string;
author: string;
icon: string;
enabled: boolean;
commands: PluginCommand[];
schema: SchemaField[];
defaultConfig?: Record<string, any>;
config?: Record<string, any>;
}
@Injectable({
providedIn: 'root'
})
export class PluginService {
private http = inject(HttpClient);
private configService = inject(ConfigService);
private activePlugins: PluginManifest[] = [];
private pluginsSubject = new BehaviorSubject<PluginManifest[]>([]);
public plugins$ = this.pluginsSubject.asObservable();
// Dynamic state cache (e.g. client freeze status: uuid/id -> 'FROZEN' | 'UNFROZEN')
private clientFreezeMap = new Map<string, 'FROZEN' | 'UNFROZEN'>();
constructor() {
this.refreshPlugins().subscribe();
}
refreshPlugins(): Observable<PluginManifest[]> {
const apiUrl = this.configService.apiUrl;
if (!apiUrl) {
return of([]);
}
return this.http.get<PluginManifest[]>(`${apiUrl}/plugins`).pipe(
catchError(() => of([])),
tap((plugins) => {
this.activePlugins = (plugins || []).filter(p => p.enabled);
this.pluginsSubject.next(this.activePlugins);
})
);
}
getPlugins(): PluginManifest[] {
return this.activePlugins.filter(p => p.enabled);
}
hasPlugins(): boolean {
return this.getPlugins().length > 0;
}
isPluginActive(name: string): boolean {
return this.activePlugins.some(p => p.name === name && p.enabled);
}
getPlugin(name: string): PluginManifest | undefined {
return this.activePlugins.find(p => p.name === name);
}
getCommands(): PluginCommand[] {
const commands: PluginCommand[] = [];
for (const plugin of this.getPlugins()) {
if (plugin.commands && Array.isArray(plugin.commands)) {
commands.push(...plugin.commands);
}
}
return commands;
}
findCommand(slug: string): PluginCommand | undefined {
return this.getCommands().find(c => c.slug === slug);
}
getClientBadges(client: any): PluginBadge[] {
const badges: PluginBadge[] = [];
if (!client) return badges;
// Badges specific to active plugins (e.g., ogfreeze)
if (this.isPluginActive('ogfreeze')) {
const key = client.uuid || String(client.id) || client.name;
const status = this.clientFreezeMap.get(key) || client.freezeStatus;
if (status === 'FROZEN') {
badges.push({
id: 'ogfreeze',
type: 'frozen',
icon: 'ac_unit',
label: 'Congelado',
tooltip: 'Equipo protegido contra escritura con UWF'
});
} else if (status === 'UNFROZEN') {
badges.push({
id: 'ogfreeze',
type: 'unfrozen',
icon: 'lock_open',
label: 'Descongelado',
tooltip: 'Filtro de protección UWF desactivado'
});
}
}
return badges;
}
toggleFreezeStatus(clients: any[], freeze: boolean): void {
const newStatus = freeze ? 'FROZEN' : 'UNFROZEN';
for (const client of clients) {
const key = client.uuid || String(client.id) || client.name;
this.clientFreezeMap.set(key, newStatus);
}
}
getPluginConfig(pluginName: string): Record<string, any> {
const plugin = this.getPlugin(pluginName);
const defaults = plugin?.defaultConfig || {};
const storageKey = `og_plugin_config_${pluginName}`;
const saved = localStorage.getItem(storageKey);
if (saved) {
try {
return { ...defaults, ...(plugin?.config || {}), ...JSON.parse(saved) };
} catch {
return { ...defaults, ...(plugin?.config || {}) };
}
}
return { ...defaults, ...(plugin?.config || {}) };
}
savePluginConfig(pluginName: string, config: Record<string, any>): void {
const storageKey = `og_plugin_config_${pluginName}`;
localStorage.setItem(storageKey, JSON.stringify(config));
const plugin = this.getPlugin(pluginName);
if (plugin) {
plugin.config = config;
}
const apiUrl = this.configService.apiUrl;
if (apiUrl) {
this.http.post(`${apiUrl}/plugins/${pluginName}/config`, config).pipe(
catchError((err) => {
console.warn(`[PluginService] Could not persist config for ${pluginName} to backend`, err);
return of(null);
})
).subscribe();
}
}
resetPluginConfig(pluginName: string): Record<string, any> {
const plugin = this.getPlugin(pluginName);
const defaults = plugin?.defaultConfig || {};
const storageKey = `og_plugin_config_${pluginName}`;
localStorage.removeItem(storageKey);
this.savePluginConfig(pluginName, defaults);
return defaults;
}
}

View File

@ -1,4 +1,7 @@
{
"plugins": "Plugins",
"pluginsTitle": "Plugins i Extensions",
"pluginsDescription": "Gestió i personalització de mòduls instal·lats a OpenGnsys",
"imageCloning": "Imatge / Clonació",
"labelDeployed": "Desplegada",
"actionAddedSuccess": "Acció afegida correctament",

View File

@ -1,4 +1,7 @@
{
"plugins": "Plugins",
"pluginsTitle": "Plugins & Extensions",
"pluginsDescription": "Manage and customize modules installed on OpenGnsys",
"imageCloning": "Image / Cloning",
"labelDeployed": "Deployed",
"actionAddedSuccess": "Action added successfully",

View File

@ -1,4 +1,7 @@
{
"plugins": "Plugins",
"pluginsTitle": "Plugins y Extensiones",
"pluginsDescription": "Gestión y personalización de módulos instalados en OpenGnsys",
"imageCloning": "Imagen / Clonación",
"labelDeployed": "Desplegada",
"actionAddedSuccess": "Acción añadida con éxito",