feat(plugins): implement dynamic plugin architecture and bump version to 1.16.0
parent
5576757c7b
commit
056c89ea53
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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'] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue