diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ca2e76..17ab0be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/ogWebconsole/src/app/app-routing.module.ts b/ogWebconsole/src/app/app-routing.module.ts
index 3465323..ad61eb1 100644
--- a/ogWebconsole/src/app/app-routing.module.ts
+++ b/ogWebconsole/src/app/app-routing.module.ts
@@ -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'] } },
],
},
{
diff --git a/ogWebconsole/src/app/app.module.ts b/ogWebconsole/src/app/app.module.ts
index bb3b54a..9e1792c 100644
--- a/ogWebconsole/src/app/app.module.ts
+++ b/ogWebconsole/src/app/app.module.ts
@@ -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,
diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html
index ee9ddc7..119a584 100644
--- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html
+++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.html
@@ -21,7 +21,10 @@
@for (command of arrayCommands; track command.slug) {
1)"
(click)="onCommandSelect(command.slug)">
- {{ command.translationKey | translate }}
+ @if (command.icon) {
+ {{ command.icon }}
+ }
+ {{ command.translationKey ? (command.translationKey | translate) : (command.label || command.slug) }}
}
\ No newline at end of file
diff --git a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts
index f224291..a3b2574 100644
--- a/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts
+++ b/ogWebconsole/src/app/components/commands/main-commands/execute-command/execute-command.component.ts
@@ -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();
}
diff --git a/ogWebconsole/src/app/components/groups/groups.component.css b/ogWebconsole/src/app/components/groups/groups.component.css
index 006ebea..1c31626 100644
--- a/ogWebconsole/src/app/components/groups/groups.component.css
+++ b/ogWebconsole/src/app/components/groups/groups.component.css
@@ -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;
+}
+
+
diff --git a/ogWebconsole/src/app/components/groups/groups.component.html b/ogWebconsole/src/app/components/groups/groups.component.html
index ad34efa..5a367e0 100644
--- a/ogWebconsole/src/app/components/groups/groups.component.html
+++ b/ogWebconsole/src/app/components/groups/groups.component.html
@@ -406,8 +406,8 @@
-
+
{{ client.name }}
{{ client.ip }}
@@ -423,6 +423,11 @@
RemotePC
}
+ @for (badge of getPluginBadges(client); track badge.id) {
+
+ {{ badge.icon }} {{ badge.label }}
+
+ }
}
+ @for (badge of getPluginBadges(client); track badge.id) {
+
+ {{ badge.icon }} {{ badge.label }}
+
+ }
@@ -586,7 +596,7 @@
} @else if (images.length > 1) {
-
+
{{ images[0].name }}
+{{images.length - 1}}
diff --git a/ogWebconsole/src/app/components/groups/groups.component.ts b/ogWebconsole/src/app/components/groups/groups.component.ts
index 3fd0719..9810f8a 100644
--- a/ogWebconsole/src/app/components/groups/groups.component.ts
+++ b/ogWebconsole/src/app/components/groups/groups.component.ts
@@ -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;
diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css
new file mode 100644
index 0000000..3e9a8ba
--- /dev/null
+++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.css
@@ -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;
+ }
+}
diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html
new file mode 100644
index 0000000..768d788
--- /dev/null
+++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.html
@@ -0,0 +1,97 @@
+
+ @if (plugin) {
+
+
+
+ } @else {
+
+
info
+
Selecciona un plugin para ver y editar su configuración.
+
+ }
+
diff --git a/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts
new file mode 100644
index 0000000..ea11e37
--- /dev/null
+++ b/ogWebconsole/src/app/components/plugins/plugin-settings/plugin-settings.component.ts
@@ -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
= {};
+
+ 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');
+ }
+}
diff --git a/ogWebconsole/src/app/components/plugins/plugins.component.css b/ogWebconsole/src/app/components/plugins/plugins.component.css
new file mode 100644
index 0000000..80c1d5f
--- /dev/null
+++ b/ogWebconsole/src/app/components/plugins/plugins.component.css
@@ -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;
+}
diff --git a/ogWebconsole/src/app/components/plugins/plugins.component.html b/ogWebconsole/src/app/components/plugins/plugins.component.html
new file mode 100644
index 0000000..2be5eac
--- /dev/null
+++ b/ogWebconsole/src/app/components/plugins/plugins.component.html
@@ -0,0 +1,42 @@
+
+
+
+ @if (plugins.length > 0) {
+
+
+ @for (plugin of plugins; track plugin.name) {
+
+
+ {{ plugin.icon }}
+ {{ plugin.title }}
+
+
+
+
+ }
+
+
+ } @else {
+
+
extension_off
+
No hay plugins instalados
+
+ Actualmente no hay ninguna extensión o plugin registrado en este servidor OpenGnsys.
+ Cuando instales un paquete compatible (como og-plugin-freeze), aparecerá automáticamente aquí.
+
+
+ }
+
diff --git a/ogWebconsole/src/app/components/plugins/plugins.component.ts b/ogWebconsole/src/app/components/plugins/plugins.component.ts
new file mode 100644
index 0000000..8d82154
--- /dev/null
+++ b/ogWebconsole/src/app/components/plugins/plugins.component.ts
@@ -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();
+ }
+}
diff --git a/ogWebconsole/src/app/layout/sidebar/sidebar.component.html b/ogWebconsole/src/app/layout/sidebar/sidebar.component.html
index ef4eb7d..9c46b80 100644
--- a/ogWebconsole/src/app/layout/sidebar/sidebar.component.html
+++ b/ogWebconsole/src/app/layout/sidebar/sidebar.component.html
@@ -149,20 +149,31 @@
-
-
- warehouse
- {{ 'repositories' | translate }}
-
-
+ @if (auth.userCategory !== 'ou-operator' && auth.userCategory !== 'ou-minimal') {
+
+
+ warehouse
+ {{ 'repositories' | translate }}
+
+
-
-
- list
- {{ 'menus' | translate }}
-
-
+
+
+ list
+ {{ 'menus' | translate }}
+
+
+
+ @if (pluginService.hasPlugins()) {
+
+
+ extension
+ Plugins
+
+
+ }
+ }
\ No newline at end of file
diff --git a/ogWebconsole/src/app/layout/sidebar/sidebar.component.ts b/ogWebconsole/src/app/layout/sidebar/sidebar.component.ts
index 7b8055b..baa23d8 100644
--- a/ogWebconsole/src/app/layout/sidebar/sidebar.component.ts
+++ b/ogWebconsole/src/app/layout/sidebar/sidebar.component.ts
@@ -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
diff --git a/ogWebconsole/src/app/services/plugin.service.ts b/ogWebconsole/src/app/services/plugin.service.ts
new file mode 100644
index 0000000..dde7f33
--- /dev/null
+++ b/ogWebconsole/src/app/services/plugin.service.ts
@@ -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;
+ config?: Record;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PluginService {
+ private http = inject(HttpClient);
+ private configService = inject(ConfigService);
+
+ private activePlugins: PluginManifest[] = [];
+ private pluginsSubject = new BehaviorSubject([]);
+ public plugins$ = this.pluginsSubject.asObservable();
+
+ // Dynamic state cache (e.g. client freeze status: uuid/id -> 'FROZEN' | 'UNFROZEN')
+ private clientFreezeMap = new Map();
+
+ constructor() {
+ this.refreshPlugins().subscribe();
+ }
+
+ refreshPlugins(): Observable {
+ const apiUrl = this.configService.apiUrl;
+ if (!apiUrl) {
+ return of([]);
+ }
+
+ return this.http.get(`${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 {
+ 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): 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 {
+ const plugin = this.getPlugin(pluginName);
+ const defaults = plugin?.defaultConfig || {};
+ const storageKey = `og_plugin_config_${pluginName}`;
+ localStorage.removeItem(storageKey);
+
+ this.savePluginConfig(pluginName, defaults);
+ return defaults;
+ }
+}
diff --git a/ogWebconsole/src/locale/cat.json b/ogWebconsole/src/locale/cat.json
index d030c2d..2c6d631 100644
--- a/ogWebconsole/src/locale/cat.json
+++ b/ogWebconsole/src/locale/cat.json
@@ -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",
diff --git a/ogWebconsole/src/locale/en.json b/ogWebconsole/src/locale/en.json
index 1f5a0ad..a3b21f9 100644
--- a/ogWebconsole/src/locale/en.json
+++ b/ogWebconsole/src/locale/en.json
@@ -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",
diff --git a/ogWebconsole/src/locale/es.json b/ogWebconsole/src/locale/es.json
index 2321360..7473d93 100644
--- a/ogWebconsole/src/locale/es.json
+++ b/ogWebconsole/src/locale/es.json
@@ -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",