New minor version. Several changes. New release. #97

Merged
maranda merged 34 commits from devel into main 2026-07-30 13:33:52 +02:00
57 changed files with 1674 additions and 828 deletions

View File

@ -1,5 +1,29 @@
# Changelog
## [1.8.2] - 2026-07-27
### Added
- Task Logs (`task-logs.component`): Consume `output` and `finishedAt` properties from Mercure events to update trace outputs in real-time without reloading the page.
### Fixed
- Creación de imágenes (`create-image.component`): Al seleccionar la acción "Actualizar Imagen", ahora se filtran correctamente las imágenes para mostrar solo aquellas que pertenecen al repositorio objetivo asignado al cliente.
- Menú de opciones de cliente (`groups.component.html`): Unificado el menú de acciones (tres puntos verticales) usando `matMenuContent`. Se elimina la duplicidad de código que causaba divergencias de comportamiento entre la vista tarjeta y la vista lista. Ahora la opción de "Logs en tiempo real" está siempre disponible en ambas vistas, al igual que los bloqueos al sincronizar.
- Detalles de tareas (`OutputDialogComponent`): Ampliado el ancho de la ventana modal de detalles a `70vw` para facilitar la lectura de trazas largas y comandos de OGAgent/OGLive.
- Repositorios Git (`show-git-images.component`): Corregido el cierre abrupto de la ventana modal al borrar un repositorio. Ahora, la interfaz recarga la lista de repositorios, permitiendo seguir operando sin tener que volver a abrir el gestor.
## [1.8.1] - 2026-07-17
### Added
- Formulario de despliegue (`Deploy Image`): Añadido selector de repositorio origen (OG Repository) al menú de despliegue para permitir al usuario elegir desde dónde descargar la imagen.
### Changed
- Modal de partición de arranque (`boot-so-partition`): Refactorizado para usar el componente compartido `app-disk-partition-selector`, eliminando código duplicado y unificando el diseño y comportamiento de la selección de discos y particiones con el resto de modales.
### Fixed
- Detalles de tareas (`OutputDialogComponent`): Corregido el formateo de los detalles mostrados desde los agentes (OGAgent) para renderizar el texto en crudo manteniendo los saltos de línea (usando pre-wrap por CSS y <pre> tag), y dándole formato monoespaciado en lugar de intentar forzar un parseo estricto JSON que rompía la salida de comandos.
- Formulario de despliegue (`Deploy Image`): Solucionado el problema que ocultaba las comandos (instructions) cuando el despliegue era en modo caché. El código ha sido refactorizado y limpiado usando directivas estructurales estándar de Angular (`*ngIf`, `*ngFor`).
- Roles: Permisos actualizados en vistas para reflejar recientes cambios de backend en la gestión de perfiles de software.
## [1.8.0] - 2026-06-16
### Added

View File

@ -54,6 +54,11 @@ test.describe('OU Operator — solo grupos y operaciones de equipos', () => {
const finalUrl = await navigateTo(page, '/clients/run-script');
expect(finalUrl).toContain('/clients/run-script');
});
test('accede a /commands-logs (trazas de sus acciones)', async ({ page }) => {
const finalUrl = await navigateTo(page, '/commands-logs');
expect(finalUrl).toContain('/commands-logs');
});
});
// ── GUI en /groups: botones de crear OU/cliente ocultos ─────────────────────

View File

@ -69,7 +69,7 @@ const routes: Routes = [
{ path: 'commands', component: CommandsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-groups', component: CommandsGroupsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-task', component: CommandsTaskComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-logs', component: TaskLogsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-logs', component: TaskLogsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'calendars', component: CalendarComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/deploy-image', component: DeployImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },

View File

@ -172,12 +172,14 @@ import { ShowTaskScheduleComponent } from './components/commands/commands-task/s
import { ShowTaskScriptComponent } from './components/commands/commands-task/show-task-script/show-task-script.component';
import { CreateTaskScriptComponent } from './components/commands/commands-task/create-task-script/create-task-script.component';
import { ViewParametersModalComponent } from './components/commands/commands-task/show-task-script/view-parameters-modal/view-parameters-modal.component';
import { ViewScriptModalComponent } from './components/commands/commands-task/show-task-script/view-script-modal/view-script-modal.component';
import { OutputDialogComponent } from './components/task-logs/output-dialog/output-dialog.component';
import { ClientTaskLogsComponent } from './components/task-logs/client-task-logs/client-task-logs.component';
import { BootSoPartitionComponent } from './components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component';
import { RemoveCacheImageComponent } from './components/commands/main-commands/execute-command/remove-cache-image/remove-cache-image.component';
import { ChangeParentComponent } from './components/groups/shared/change-parent/change-parent.component';
import { SoftwareProfilePartitionComponent } from './components/commands/main-commands/execute-command/software-profile-partition/software-profile-partition.component';
import { ExecuteTaskNowComponent } from './components/commands/main-commands/execute-command/execute-task-now/execute-task-now.component';
import { ClientPendingTasksComponent } from './components/task-logs/client-pending-tasks/client-pending-tasks.component';
import { QueueConfirmationModalComponent } from './shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { ModalOverlayComponent } from './shared/modal-overlay/modal-overlay.component';
@ -316,12 +318,14 @@ registerLocaleData(localeEs, 'es-ES');
ShowTaskScriptComponent,
CreateTaskScriptComponent,
ViewParametersModalComponent,
ViewScriptModalComponent,
OutputDialogComponent,
ClientTaskLogsComponent,
BootSoPartitionComponent,
RemoveCacheImageComponent,
ChangeParentComponent,
SoftwareProfilePartitionComponent,
ExecuteTaskNowComponent,
ClientPendingTasksComponent,
ModalOverlayComponent,
ScrollToTopComponent,

View File

@ -15,30 +15,48 @@
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelRole' | translate }}</mat-label>
<mat-select formControlName="role" required>
<mat-option *ngFor="let group of userGroups" [value]="group['@id']">
{{ group.name }}
</mat-option>
@for (group of userGroups; track group['@id']) {
<mat-option [value]="group['@id']">
{{ group.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelOrganizationalUnit' | translate }}</mat-label>
<mat-select multiple formControlName="organizationalUnits">
<mat-option *ngFor="let unit of organizationalUnits" [value]="unit['@id']">
{{ unit.name }}
</mat-option>
@for (unit of organizationalUnits; track unit['@id']) {
<mat-option [value]="unit['@id']">
{{ unit.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'cardView' | translate }}</mat-label>
<mat-select formControlName="groupsView" required>
<mat-option *ngFor="let option of views" [value]="option.value">
{{ option.name }}
</mat-option>
@for (option of views; track option.value) {
<mat-option [value]="option.value">
{{ option.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'userLanguage' | translate }}</mat-label>
<mat-select formControlName="language">
@for (lang of availableLanguages; track lang.code) {
<mat-option [value]="lang.code">
{{ lang.label }}
</mat-option>
}
</mat-select>
<mat-hint>{{ 'userLanguageHint' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'clientsPerPage' | translate }}</mat-label>
<input matInput formControlName="clientsPerPage" type="number" min="1" [placeholder]="'default20' | translate">

View File

@ -36,6 +36,8 @@ export class AddUserModalComponent implements OnInit {
{ value: 'list', name: 'Listado' },
];
readonly availableLanguages: { code: string; label: string }[];
constructor(
public dialogRef: MatDialogRef<AddUserModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
@ -46,12 +48,15 @@ export class AddUserModalComponent implements OnInit {
private configService: ConfigService
) {
this.baseUrl = this.configService.apiUrl;
const langs = this.configService.languages;
this.availableLanguages = Object.keys(langs).map(code => ({ code, label: langs[code] }));
this.userForm = this.fb.group({
username: ['', Validators.required],
password: ['', Validators.required],
role: ['', Validators.required],
groupsView: ['card', Validators.required],
clientsPerPage: [null, [Validators.min(1)]],
language: ['es'],
organizationalUnits: [[]]
});
@ -87,7 +92,8 @@ export class AddUserModalComponent implements OnInit {
role: response.userGroups.length > 0 ? response.userGroups[0]['@id'] : null,
organizationalUnits: organizationalUnitIds,
groupsView: response.groupsView,
clientsPerPage: response.clientsPerPage ?? null
clientsPerPage: response.clientsPerPage ?? null,
language: response.language ?? 'es'
});
this.userId = response['@id'];
@ -114,7 +120,8 @@ export class AddUserModalComponent implements OnInit {
enabled: true,
userGroups: [this.userForm.value.role],
groupsView: this.userForm.value.groupsView,
clientsPerPage: this.userForm.value.clientsPerPage || null
clientsPerPage: this.userForm.value.clientsPerPage || null,
language: this.userForm.value.language || null
};
if (!this.userId && this.userForm.value.password) {

View File

@ -321,18 +321,21 @@ mat-spinner {
color: #666;
}
/* Contenido del script en celda */
.script-content-cell {
background-color: #f5f5f5;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
white-space: pre-wrap;
font-size: 12px;
max-width: 300px;
/* Vista previa del script en celda (una sola línea, altura de fila fija) */
.script-preview-cell {
display: flex;
align-items: center;
gap: 4px;
max-width: 260px;
}
.script-preview-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #555;
}
.no-content-hint {

View File

@ -50,8 +50,11 @@
<ng-container matColumnDef="content">
<th mat-header-cell *matHeaderCellDef>{{ 'labelScript' | translate }}</th>
<td mat-cell *matCellDef="let item">
<div *ngIf="item.content" class="script-content-cell">
<code>{{ item.content }}</code>
<div *ngIf="item.content" class="script-preview-cell">
<span class="script-preview-text">{{ item.content }}</span>
<button mat-icon-button (click)="openScriptModal(item.content)" [matTooltip]="'viewScript' | translate">
<mat-icon>visibility</mat-icon>
</button>
</div>
<span *ngIf="!item.content" class="no-content-hint">
({{ 'viewParameters' | translate }})

View File

@ -10,6 +10,7 @@ import { CreateTaskScheduleComponent } from "../create-task-schedule/create-task
import { CreateTaskScriptComponent } from "../create-task-script/create-task-script.component";
import { DeleteModalComponent } from "../../../../shared/delete_modal/delete-modal/delete-modal.component";
import { ViewParametersModalComponent } from "./view-parameters-modal/view-parameters-modal.component";
import { ViewScriptModalComponent } from "./view-script-modal/view-script-modal.component";
@Component({
selector: 'app-show-task-script',
@ -119,6 +120,13 @@ export class ShowTaskScriptComponent implements OnInit {
});
}
openScriptModal(content: string): void {
this.dialog.open(ViewScriptModalComponent, {
width: '700px',
data: content
});
}
createNewScript(): void {
const dialogRef = this.dialog.open(CreateTaskScriptComponent, {
width: '800px',

View File

@ -0,0 +1,12 @@
.script-full-content {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
max-height: 60vh;
overflow-y: auto;
}

View File

@ -0,0 +1,7 @@
<h2 mat-dialog-title>{{ 'labelScript' | translate }}</h2>
<mat-dialog-content>
<pre class="script-full-content">{{ data }}</pre>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="close()">{{ 'buttonClose' | translate }}</button>
</mat-dialog-actions>

View File

@ -0,0 +1,18 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-view-script-modal',
templateUrl: './view-script-modal.component.html',
styleUrl: './view-script-modal.component.css'
})
export class ViewScriptModalComponent {
constructor(
public dialogRef: MatDialogRef<ViewScriptModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
close(): void {
this.dialogRef.close();
}
}

View File

@ -53,42 +53,13 @@
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<div *ngIf="!loading" class="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">{{ 'buttonSelectPartition' | translate }}</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header | translate }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
[partitions]="dataSource.data"
[columns]="columns"
[displayedColumns]="displayedColumns"
[disableCondition]="isPartitionDisabled">
</app-disk-partition-selector>
</div>
</mat-dialog-content>

View File

@ -59,6 +59,10 @@ export class BootSoPartitionComponent implements OnInit {
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
isPartitionDisabled = (partition: any): boolean => {
return !partition.operativeSystem;
};
constructor(
@Inject(MAT_DIALOG_DATA) public data: { clients: any },
private dialogRef: MatDialogRef<BootSoPartitionComponent>,

View File

@ -11,6 +11,7 @@ import { RemoveCacheImageComponent } from "./remove-cache-image/remove-cache-ima
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';
@Component({
selector: 'app-execute-command',
@ -43,6 +44,7 @@ export class ExecuteCommandComponent implements OnInit {
{ translationKey: 'executeCommands.runScript', slug: 'run-script', disabled: false },
{ translationKey: 'executeCommands.refresh', slug: 'refresh', disabled: false },
{ translationKey: 'executeCommands.deleteLease', slug: 'delete-lease', disabled: false },
{ translationKey: 'executeCommands.executeTask', slug: 'execute-task', disabled: false },
];
client: any = {};
@ -87,7 +89,8 @@ export class ExecuteCommandComponent implements OnInit {
'remove-cache-image',
'partition',
'refresh',
'delete-lease'
'delete-lease',
'execute-task'
],
'ou-minimal': [
'power-on',
@ -138,6 +141,10 @@ export class ExecuteCommandComponent implements OnInit {
command.disabled = !states.every(state => state === 'og-live');
}
if (command.slug === 'execute-task') {
command.disabled = this.clientData.length === 0;
}
return command;
});
}
@ -194,6 +201,18 @@ export class ExecuteCommandComponent implements OnInit {
if (action === 'delete-lease') {
this.deleteClientLease();
}
if (action === 'execute-task') {
this.openExecuteTaskModal();
}
}
openExecuteTaskModal(): void {
this.dialog.open(ExecuteTaskNowComponent, {
width: '500px',
height: 'auto',
data: { clients: this.clientData }
});
}
rebootClient(): void {

View File

@ -0,0 +1,41 @@
.form-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.full-width {
width: 100%;
}
.hint-text {
margin: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
}
.mat-dialog-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.mat-dialog-content {
max-height: 60vh;
overflow-y: auto;
}
.mat-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}

View File

@ -0,0 +1,34 @@
<h2 mat-dialog-title>{{ 'executeTaskNowModalTitle' | translate }}</h2>
<mat-dialog-content class="form-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectTask' | translate }}</mat-label>
<input type="text" matInput [formControl]="taskControl" [matAutocomplete]="taskAuto"
[placeholder]="'labelSearchTask' | translate" #taskInput
#taskAutoTrigger="matAutocompleteTrigger">
<button type="button" mat-icon-button matSuffix tabindex="-1"
[disabled]="tasksLoading || tasks.length === 0"
(click)="taskInput.focus(); taskAutoTrigger.openPanel()">
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-autocomplete #taskAuto="matAutocomplete"
[displayWith]="displayFn" autoActiveFirstOption
(optionSelected)="onTaskSelected($event.option.value)">
<mat-option *ngFor="let task of filteredTasks$ | async" [value]="task">
{{ task.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<p *ngIf="tasksLoading" class="hint-text">{{ 'loadingDots' | translate }}</p>
<p *ngIf="!tasksLoading && tasks.length === 0">{{ 'noTasksAvailableMessage' | translate }}</p>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="closeModal()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="executeNow()" [disabled]="!selectedTask">
{{ 'executeButton' | translate }}
</button>
</mat-dialog-actions>

View File

@ -0,0 +1,105 @@
import { inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { HttpClient } from '@angular/common/http';
import { FormControl } from '@angular/forms';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { ToastrService } from 'ngx-toastr';
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-execute-task-now',
templateUrl: './execute-task-now.component.html',
styleUrls: ['./execute-task-now.component.css']
})
export class ExecuteTaskNowComponent implements OnInit {
public translate = inject(TranslateService);
baseUrl: string;
tasks: any[] = [];
tasksLoading = true;
taskControl = new FormControl();
filteredTasks$!: Observable<any[]>;
selectedTask: any = null;
private tasks$ = new BehaviorSubject<any[]>([]);
constructor(
private dialogRef: MatDialogRef<ExecuteTaskNowComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private http: HttpClient,
private configService: ConfigService,
private toastService: ToastrService,
) {
this.baseUrl = this.configService.apiUrl;
}
ngOnInit(): void {
this.loadTasks();
this.filteredTasks$ = combineLatest([
this.taskControl.valueChanges.pipe(startWith('')),
this.tasks$
]).pipe(
map(([value]) => this._filterTasks(typeof value === 'string' ? value : value?.name))
);
}
loadTasks(): void {
this.http.get<any>(`${this.baseUrl}/command-tasks?page=1&itemsPerPage=100`).subscribe(
response => {
this.tasks = response['hydra:member'] || [];
this.tasksLoading = false;
this.tasks$.next(this.tasks);
},
error => {
this.tasksLoading = false;
this.toastService.error(this.translate.instant('errorLoadingTasks'), error);
}
);
}
private _filterTasks(value: string): any[] {
const filterValue = (value || '').toLowerCase();
return this.tasks.filter(task => task.name.toLowerCase().includes(filterValue));
}
displayFn(task: any): string {
return task && task.name ? task.name : '';
}
onTaskSelected(task: any): void {
this.selectedTask = task;
}
executeNow(): void {
if (!this.selectedTask) {
return;
}
const clients = this.data?.clients || [];
if (clients.length === 0) {
this.toastService.error(this.translate.instant('noClientsSelected'));
return;
}
const payload = {
clients: clients.map((client: any) => client['@id'] || `/clients/${client.uuid}`)
};
this.http.post(`${this.baseUrl}/command-tasks/${this.selectedTask.uuid}/execute`, payload).subscribe({
next: () => {
this.toastService.success(this.translate.instant('taskExecutedSuccessfully'));
this.dialogRef.close(true);
},
error: () => {
this.toastService.error(this.translate.instant('errorExecutingTaskNow'));
}
});
}
closeModal(): void {
this.dialogRef.close(false);
}
}

View File

@ -4,6 +4,7 @@ import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { ConfigService } from '@services/config.service';
import { ToastrService } from "ngx-toastr";
import { AuthService } from '@services/auth.service';
@Component({
selector: 'app-global-status',
@ -67,7 +68,8 @@ export class GlobalStatusComponent implements OnInit {
constructor(
private configService: ConfigService,
private toastService: ToastrService,
private http: HttpClient
private http: HttpClient,
private auth: AuthService
) {
this.baseUrl = this.configService.apiUrl;
this.dhcpsApiUrl = `${this.baseUrl}/dhcp-servers`;
@ -115,7 +117,18 @@ export class GlobalStatusComponent implements OnInit {
});
}
/**
* Las sincronizaciones con la infraestructura (subredes DHCP, plantillas PXE y
* ogLives) están restringidas a ROLE_SUPER_ADMIN en ogCore; para el resto de
* perfiles devuelven "Access Denied", así que ni siquiera se lanzan.
*/
private get canSyncInfrastructure(): boolean {
return this.auth.isSuperAdmin;
}
syncSubnets() {
if (!this.canSyncInfrastructure) return;
this.http.get<any>(`${this.dhcpsApiUrl}?page=1&itemsPerPage=10`).subscribe(data => {
const dhcps = data['hydra:member'] || [];
if (dhcps.length === 0) return;
@ -155,6 +168,7 @@ export class GlobalStatusComponent implements OnInit {
}
syncTemplates() {
if (!this.canSyncInfrastructure) return;
if (this.bootServers.length === 0) return;
let pending = this.bootServers.length;
@ -189,6 +203,7 @@ export class GlobalStatusComponent implements OnInit {
}
syncOgLives(): void {
if (!this.canSyncInfrastructure) return;
if (this.bootServers.length === 0) return;
let pending = this.bootServers.length;

View File

@ -152,7 +152,8 @@ export class CreateClientImageComponent implements OnInit{
}
loadImages() {
const url = `${this.baseUrl}/images?created=false&type=${this.imageType}&page=1&itemsPerPage=100`;
const repoFilter = this.selectedRepository ? `&repositoryId=${this.selectedRepository.id}` : '';
const url = `${this.baseUrl}/images?created=false&type=${this.imageType}${repoFilter}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
(response: any) => {
this.images = response['hydra:member'];

View File

@ -52,7 +52,8 @@
</div>
<div class="clients-grid">
<div *ngFor="let client of clientData" class="client-item">
@for (client of clientData; track client) {
<div class="client-item">
<div class="client-card" (click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" [matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above" matTooltipClass="custom-tooltip">
@ -73,6 +74,7 @@
</mat-radio-group>
</div>
</div>
}
</div>
</mat-expansion-panel>
@ -99,49 +101,89 @@
</div>
<!-- Campos monolítica -->
<div class="monolithic-row" *ngIf="imageType === 'monolithic'">
<mat-form-field appearance="fill" class="half-width">
@if (imageType === 'monolithic') {
<div class="monolithic-row" style="display: flex; flex-direction: column; gap: 10px;">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectDeployMethod' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()">
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option>
@for (method of allMethods; track method.value) {
<mat-option [value]="method.value">{{ method.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectRepository' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedRepository" (selectionChange)="onRepositorySelected($event.value)" [compareWith]="compareRepositories" [disabled]="loadingAllRepositories">
@for (repo of allImageRepositories; track repo.uuid) {
<mat-option [value]="repo">{{ repo.name }} ({{ repo.ip }})</mat-option>
}
</mat-select>
@if (loadingAllRepositories) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectImagePrompt' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" [disabled]="!imageType">
<mat-option *ngFor="let image of images" [value]="image">
@for (image of images; track image.uuid || $index) {
<mat-option [value]="image">
<div class="unit-name"> {{ image.name }}</div>
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
<!-- Campos Git -->
<div *ngIf="imageType === 'git'" class="filters-row git-gap">
<mat-form-field appearance="fill" style="width: 300px;">
@if (imageType === 'git') {
<div class="git-gap" style="display: flex; flex-direction: column; gap: 10px;">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'selectRepository' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedRepository" (selectionChange)="onRepositorySelected($event.value)" [compareWith]="compareRepositories" [disabled]="loadingAllRepositories">
@for (repo of allImageRepositories; track repo.uuid) {
<mat-option [value]="repo">{{ repo.name }} ({{ repo.ip }})</mat-option>
}
</mat-select>
@if (loadingAllRepositories) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'buttonSelectRepository' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositoryChange($event.value)"
[disabled]="loadingRepositories">
<mat-option *ngFor="let repo of repositories" [value]="repo">
@for (repo of repositories; track repo) {
<mat-option [value]="repo">
{{ repo }}
</mat-option>
}
</mat-select>
<mat-icon matSuffix *ngIf="loadingRepositories">hourglass_empty</mat-icon>
@if (loadingRepositories) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" style="width: 300px;">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'buttonSelectBranch' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onGitBranchChange()"
[disabled]="loadingBranches || !selectedRepository">
<mat-option *ngFor="let branch of branches" [value]="branch">
[disabled]="loadingBranches || !selectedGitRepository">
@for (branch of branches; track branch) {
<mat-option [value]="branch">
{{ branch }}
</mat-option>
}
</mat-select>
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
@if (loadingBranches) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" style="width: 200px;">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'labelTag' | translate }}</mat-label>
<mat-select [(ngModel)]="showOnlyTagged" (selectionChange)="onTagFilterChange()" [disabled]="!commits.length">
<mat-option [value]="false">{{ 'buttonNo' | translate }}</mat-option>
@ -149,6 +191,7 @@
</mat-select>
</mat-form-field>
</div>
}
<!-- Configuración avanzada del método (Multicast / P2P) -->
@if (imageType === 'monolithic' && selectedMethod && needsAdvancedConfig(selectedMethod)) {
@ -182,9 +225,11 @@
}
<div *ngIf="errorMessage" class="error-message">
@if (errorMessage) {
<div class="error-message">
{{ errorMessage }}
</div>
}
</div>
@ -221,6 +266,22 @@
</div>
}
<!-- Contenedor para mostrar las instrucciones en cualquier modo -->
@if (showInstructions) {
<div class="instructions-box" style="margin-bottom: 20px;">
<mat-card class="instructions-card">
<mat-card-title>
{{ 'generatedInstructions' | translate }}<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
</div>
}
<!-- Sección: Selección de partición (oculta en modo solo-caché) -->
@if (deployMode !== 'update-cache') {
<div class="form-section compact-section partition-select-section" id="partition-selection">
@ -229,18 +290,6 @@
{{ 'partitionSelection' | translate }}</div>
<div class="partition-table-container">
<div *ngIf="showInstructions" class="instructions-box">
<mat-card class="instructions-card">
<mat-card-title>
{{ 'generatedInstructions' | translate }}<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
</div>
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
@ -274,7 +323,8 @@
</div>
<!-- Tabla de commits Git (solo cuando hay commits cargados) -->
<div *ngIf="imageType === 'git' && filteredCommits.length > 0" class="form-section compact-section">
@if (imageType === 'git' && filteredCommits.length > 0) {
<div class="form-section compact-section">
<div class="form-section-title">
<mat-icon>history</mat-icon>
{{ 'commitSelection' | translate }}</div>
@ -309,11 +359,15 @@
<th mat-header-cell *matHeaderCellDef>{{ 'labelTags' | translate }}</th>
<td mat-cell *matCellDef="let commit">
<mat-chip-list>
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected class="custom-tag-chip"
@for (tag of commit.tags; track tag) {
<mat-chip color="primary" selected class="custom-tag-chip"
style="background: rgba(102, 126, 234, 0.1) !important; color: #667eea !important; border: 1px solid rgba(102, 126, 234, 0.3) !important; border-radius: 8px !important; font-weight: 500 !important; font-size: 10px !important; padding: 2px 6px !important; margin: 1px !important; box-shadow: none !important;">
{{ tag }}
</mat-chip>
<span *ngIf="!commit.tags || commit.tags.length === 0" class="no-tags-text">{{ 'noTags' | translate }}</span>
}
@if (!commit.tags || commit.tags.length === 0) {
<span class="no-tags-text">{{ 'noTags' | translate }}</span>
}
</mat-chip-list>
</td>
</ng-container>
@ -322,6 +376,7 @@
</table>
</div>
</div>
}
</div>

View File

@ -63,6 +63,8 @@ export class DeployImageComponent implements OnInit {
selectedModelClient: any = null;
filteredPartitions: any[] = [];
allImageRepositories: any[] = [];
loadingAllRepositories: boolean = false;
selectedRepository: any = null;
imageType: string = 'monolithic';
@ -145,6 +147,42 @@ export class DeployImageComponent implements OnInit {
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
this.loadAllRepositories();
}
loadAllRepositories() {
this.loadingAllRepositories = true;
this.http.get<any>(`${this.baseUrl}/image-repositories?page=1&itemsPerPage=10000`).subscribe(
(response: any) => {
this.allImageRepositories = response?.['hydra:member'] || [];
this.loadingAllRepositories = false;
// Si ya había un selectedRepository cargado por el cliente, asegurarse de enlazar la misma referencia del array
if (this.selectedRepository) {
const match = this.allImageRepositories.find(r => r.uuid === this.selectedRepository.uuid);
if (match) {
this.selectedRepository = match;
}
}
},
(error) => {
console.error(this.translate.instant('errorLoadingRepositories1'), error);
this.loadingAllRepositories = false;
}
);
}
compareRepositories(r1: any, r2: any): boolean {
return r1 && r2 ? r1.uuid === r2.uuid : r1 === r2;
}
onRepositorySelected(repo: any) {
this.selectedRepository = repo;
if (this.imageType === 'git') {
this.selectedGitRepository = '';
this.selectedBranch = '';
this.loadGitRepositories();
}
this.loadImages();
}
onImageTypeSelected(event: any) {
@ -217,6 +255,23 @@ export class DeployImageComponent implements OnInit {
this.filteredPartitions = fullClientData.partitions;
this.selectedRepository = fullClientData.repository ?? null;
if (this.selectedRepository?.uuid && this.allImageRepositories.length > 0) {
const match = this.allImageRepositories.find(r => r.uuid === this.selectedRepository.uuid);
if (match) {
this.selectedRepository = match;
}
} else if (this.selectedRepository?.uuid) {
// Fallback por si la llamada de loadAllRepositories no ha terminado
this.http.get(`${this.baseUrl}/image-repositories/${this.selectedRepository.uuid}`).subscribe(
(repository: any) => {
this.selectedRepository = repository;
},
(error) => {
console.error(this.translate.instant('errorLoadingRepositories1'), error);
}
);
}
if (fullClientData.partitions) {
this.filteredPartitions = fullClientData.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
@ -256,7 +311,9 @@ export class DeployImageComponent implements OnInit {
if (this.imageType === 'monolithic') {
url = `${this.baseUrl}/image-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`;
} else {
url = `${this.baseUrl}/git-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`;
// Para 'git', la carga de repositorios y ramas se maneja con loadGitRepositories() y loadGitBranches()
this.images = [];
return;
}
this.http.get(url).subscribe(
@ -586,9 +643,79 @@ export class DeployImageComponent implements OnInit {
});
}
generateOgInstructions() {
this.showInstructions = true;
this.ogInstructions = `og-deploy-image --image ${this.selectedImage.name} --partition ${this.selectedPartition?.partitionNumber || 1} --method ${this.selectedMethod}`;
this.ogInstructions = this.imageType === 'git'
? this.buildGitOgInstructions()
: this.buildMonolithicOgInstructions();
}
private getProto(method: string | null): string {
switch (method) {
case 'uftp-direct':
case 'udpcast-direct':
return 'MULTICAST-DIRECT';
case 'multicast':
case 'uftp':
case 'udpcast':
return 'MULTICAST';
case 'unicast-direct':
return 'UNICAST-DIRECT';
case 'unicast':
return 'UNICAST';
case 'p2p':
return 'TORRENT';
default:
return (method || '').toUpperCase();
}
}
private getProtoOpt(method: string | null): string {
if (method === 'udpcast' || method === 'uftp' || method === 'udpcast-direct') {
const mcastMode = `${this.mcastMode}-duplex`;
const mcastSpeed = `${this.mcastSpeed}M`;
return `${this.mcastPort}:${mcastMode}:${this.mcastIp}:${mcastSpeed}:${this.mcastMaxClients}:${this.mcastMaxTime}`;
}
if (method === 'p2p') {
return `${this.p2pMode}:${this.p2pTime}`;
}
return '';
}
private buildMonolithicOgInstructions(): string {
const method = this.selectedMethod;
const proto = this.getProto(method);
const protoOpt = this.getProtoOpt(method);
const disk = String(this.selectedPartition?.diskNumber || 1);
const par = String(this.selectedPartition?.partitionNumber || 1);
const imgname = this.selectedImage?.name || '<image>';
const repo = this.selectedRepository?.ip || '<repository-ip>';
const effectiveDeployMode = this.isDirectMethod() ? 'deploy' : this.deployMode;
const isCache = effectiveDeployMode === 'update-cache';
const restaurarArgs = [disk, par, imgname, repo, proto, protoOpt].filter(a => a !== '').join(' ');
const scriptName = isCache ? 'ActualizarCache' : 'RestaurarImagen';
const innerCommand = isCache
? ['updateCache', repo, `/${imgname}.img`, proto, protoOpt].filter(a => a !== '').join(' ')
: ['deployImage', repo, imgname, disk, par, proto, protoOpt].filter(a => a !== '').join(' ');
return `${scriptName} ${restaurarArgs}\n ↳ ${innerCommand}`;
}
private buildGitOgInstructions(): string {
const disk = String(this.selectedPartition?.diskNumber || 1);
const par = String(this.selectedPartition?.partitionNumber || 1);
const repo = this.selectedRepository?.ip || '<repository-ip>';
const imageName = this.selectedGitRepository || '<repository-name>';
const branch = this.selectedBranch || '<branch>';
const commit = this.selectedCommit?.hexsha || '<commit>';
return `RestaurarImagenGit --disk ${disk} --partition ${par} --repository ${repo} ` +
`--image-name ${imageName} --branch ${branch} --commit ${commit}`;
}

File diff suppressed because it is too large Load Diff

View File

@ -168,7 +168,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.updateGridCols();
this.refreshData();
this.refreshData(localStorage.getItem('lastSelectedOU') ?? undefined);
window.addEventListener('resize', this.updateGridCols);
this.selectedClients.filterPredicate = (client: Client, filter: string): boolean => {
@ -340,12 +340,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.treeDataSource.data = [...this.originalTreeData];
if (selectedNodeIdOrUuid) {
this.selectedNode = this.findNodeByIdOrUuid(this.treeDataSource.data, selectedNodeIdOrUuid);
}
if (this.selectedNode) {
this.treeControl.collapseAll();
this.expandPathToNode(this.selectedNode);
this.fetchClientsForNode(this.selectedNode, selectedClientsBeforeEdit);
}
if (selectedNodeIdOrUuid && this.selectedNode) {
this.treeControl.collapseAll();
this.expandPathToNode(this.selectedNode);
this.fetchClientsForNode(this.selectedNode, selectedClientsBeforeEdit);
} else {
this.treeControl.collapseAll();
if (this.treeDataSource.data.length > 0) {
@ -419,8 +419,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
onNodeClick(event: MouseEvent, node: TreeNode): void {
event.stopPropagation();
this.selectedNode = node;
this.persistSelectedNode(node);
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
const checkStatusAfterLoad = node.type === NodeType.Classroom || node.type === NodeType.ClientsGroup;
this.fetchClientsForNode(node, selectedClientsBeforeEdit, checkStatusAfterLoad);
}
@ -428,10 +429,17 @@ export class GroupsComponent implements OnInit, OnDestroy {
onMenuClick(event: Event, node: any): void {
event.stopPropagation();
this.selectedNode = node;
this.persistSelectedNode(node);
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
this.fetchClientsForNode(node, selectedClientsBeforeEdit);
}
private persistSelectedNode(node: TreeNode): void {
if (node.uuid) {
localStorage.setItem('lastSelectedOU', node.uuid);
}
}

View File

@ -83,4 +83,22 @@ mat-option .unit-path {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 20px;
}
.hardware-profile-box {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0 12px 0;
}
.hardware-profile-box .form-field {
flex: 1;
min-width: 0;
width: auto;
}
.hardware-profile-link {
margin-left: auto;
color: #3f51b5;
}

View File

@ -139,6 +139,26 @@
</div>
</fieldset>
@if (isEditMode) {
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'labelHardwareProfile' | translate }}</legend>
<div class="hardware-profile-box">
<mat-form-field class="form-field">
<mat-select formControlName="hardwareProfile">
<mat-option [value]="null">{{ 'noHardwareProfileAssigned' | translate }}</mat-option>
@for (profile of hardwareProfiles; track profile['@id']) {
<mat-option [value]="profile['@id']">{{ profile.description }}</mat-option>
}
</mat-select>
</mat-form-field>
<button type="button" mat-icon-button class="hardware-profile-link" (click)="goToHardwareProfiles()"
[matTooltip]="'manageHardwareProfiles' | translate">
<mat-icon>settings</mat-icon>
</button>
</div>
</fieldset>
}
<mat-form-field class="form-field full-width">
<mat-label>{{ 'labelNotes' | translate }}</mat-label>
<textarea matInput formControlName="notes" rows="4"

View File

@ -5,6 +5,7 @@ import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr';
import { Router } from '@angular/router';
import { DataService } from '../../../services/data.service';
import { ConfigService } from '@services/config.service';
@ -24,6 +25,7 @@ export class ManageClientComponent implements OnInit {
templates: any[] = [];
repositories: any[] = [];
bootServers: any[] = [];
hardwareProfiles: any[] = [];
loading: boolean = false;
displayedColumns: string[] = ['name', 'ip'];
dialogTitle: string;
@ -41,6 +43,7 @@ export class ManageClientComponent implements OnInit {
private configService: ConfigService,
private toastService: ToastrService,
private dataService: DataService,
private router: Router,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
@ -55,7 +58,8 @@ export class ManageClientComponent implements OnInit {
this.loadParentUnits(),
this.loadBootServers(),
this.loadRepositories(),
this.loadMenus()
this.loadMenus(),
this.loadHardwareProfiles()
];
Promise.all(observables).then(() => {
@ -91,6 +95,7 @@ export class ManageClientComponent implements OnInit {
ogLive: [null],
repository: [null],
menu: [null],
hardwareProfile: [null],
maintenance: [false],
resolution: [null],
notes: ['']
@ -222,6 +227,24 @@ export class ManageClientComponent implements OnInit {
});
}
loadHardwareProfiles(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${this.baseUrl}/hardware-profiles?page=1&itemsPerPage=10000`;
this.http.get<any>(url).subscribe(
response => {
this.hardwareProfiles = response['hydra:member'];
resolve();
},
error => {
console.error(this.translate.instant('errorFetchingHardwareProfiles'), error);
this.hardwareProfiles = [];
resolve();
}
);
});
}
loadRepositories(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${this.baseUrl}/image-repositories?page=1&itemsPerPage=10000`;
@ -294,6 +317,7 @@ export class ManageClientComponent implements OnInit {
bootServer: bootServerIri,
repository: data.repository ? data.repository['@id'] : null,
menu: data.menu ? data.menu['@id'] : null,
hardwareProfile: data.hardwareProfile ? data.hardwareProfile['@id'] : null,
maintenance: data.maintenance,
resolution: data.resolution,
notes: data.notes || ''
@ -364,4 +388,9 @@ export class ManageClientComponent implements OnInit {
onNoClick(): void {
this.dialogRef.close({ success: false });
}
goToHardwareProfiles(): void {
this.dialogRef.close({ success: false });
this.router.navigate(['/hardware-profiles']);
}
}

View File

@ -62,11 +62,22 @@
.client-info {
display: flex;
flex-direction: column;
max-width: 220px;
}
.client-name {
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-more {
font-weight: 400;
font-style: italic;
color: #666;
cursor: default;
}
.client-details {
@ -74,8 +85,4 @@
flex-direction: column;
font-size: 0.75em;
color: #666;
}
.client-ip, .client-mac {
font-family: monospace;
}

View File

@ -47,11 +47,12 @@
</mat-icon>
</ng-container>
<ng-container *ngIf="column.columnDef === 'client'">
<ng-container *ngIf="column.columnDef === 'clients'">
<div *ngIf="column.cell(image) !== 'N/A'; else noClientData" class="client-info">
<div class="client-name">{{ column.cell(image).name }}</div>
<div class="client-details">
<span class="client-ip">{{ column.cell(image).ip }}</span>
<div class="client-name" *ngFor="let assignedClient of getVisibleClients(column.cell(image))">{{ assignedClient.name }}</div>
<div class="client-name client-more" *ngIf="column.cell(image).length > maxVisibleClients"
[matTooltip]="getHiddenClientNames(column.cell(image))">
{{ 'andMoreClients' | translate: { count: column.cell(image).length - maxVisibleClients } }}
</div>
</div>
<ng-template #noClientData>
@ -60,7 +61,7 @@
</ng-container>
<ng-container
*ngIf="column.columnDef !== 'isDefault' && column.columnDef !== 'installed' && column.columnDef !== 'downloadUrl' && column.columnDef !== 'client'">
*ngIf="column.columnDef !== 'isDefault' && column.columnDef !== 'installed' && column.columnDef !== 'downloadUrl' && column.columnDef !== 'clients'">
{{ column.cell(image) }}
</ng-container>
</td>

View File

@ -37,6 +37,7 @@ export class HardwareProfileComponent {
filteredClients!: Observable<any[]>;
clientControl = new FormControl();
clients: any[] = [];
maxVisibleClients: number = 3;
columns = [
{
columnDef: 'id',
@ -49,13 +50,9 @@ export class HardwareProfileComponent {
cell: (hardware: any) => hardware.description
},
{
columnDef: 'client',
header: 'client',
cell: (hardware: any) => hardware.client ? {
name: hardware.client.name || 'N/A',
ip: hardware.client.ip || 'N/A',
mac: hardware.client.mac || 'N/A'
} : 'N/A'
columnDef: 'clients',
header: 'clients',
cell: (hardware: any) => hardware.clients?.length ? hardware.clients : 'N/A'
},
{
columnDef: 'createdAt',
@ -104,16 +101,24 @@ export class HardwareProfileComponent {
}
onOptionClientSelected(selectedClient: any): void {
this.filters['client.id'] = selectedClient.id;
this.filters['clients.id'] = selectedClient.id;
this.search();
}
clearClientFilter(event: Event, clientSearchClientInput: any): void {
clientSearchClientInput.value = '';
this.filters['client.id'] = '';
this.filters['clients.id'] = '';
this.search();
}
getVisibleClients(clients: any): any[] {
return (clients || []).slice(0, this.maxVisibleClients);
}
getHiddenClientNames(clients: any): string {
return (clients || []).slice(this.maxVisibleClients).map((client: any) => client.name).join(', ');
}
private _filterClients(value: string): any[] {
const filterValue = value.toLowerCase();

View File

@ -32,15 +32,6 @@
<input matInput formControlName="comments" name="comments">
</mat-form-field>
<mat-form-field appearance="fill" class="form-field">
<mat-label>{{ 'softwareProfile' | translate }}</mat-label>
<mat-select formControlName="softwareProfile">
<mat-option *ngFor="let profile of softwareProfiles" [value]="profile['@id']">
{{ profile.description }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="remotePc">
{{ 'labelRemotePc' | translate }}
</mat-checkbox>

View File

@ -18,7 +18,6 @@ export class CreateImageComponent implements OnInit {
baseUrl: string;
imageForm: FormGroup<any>;
imageId: string | null = null;
softwareProfiles: any[] = [];
repositories: any[] = [];
loading: boolean = false;
isEditMode: boolean = false;
@ -41,7 +40,6 @@ export class CreateImageComponent implements OnInit {
comments: [''],
remotePc: [false],
isGlobal: [false],
softwareProfile: [''],
imageRepositories: [[], Validators.required],
});
}
@ -54,7 +52,6 @@ export class CreateImageComponent implements OnInit {
} else {
this.loading = false;
}
this.fetchSoftwareProfiles();
this.fetchRepositories();
}
@ -67,7 +64,6 @@ export class CreateImageComponent implements OnInit {
comments: response.comments,
remotePc: response.remotePc,
isGlobal: response.isGlobal,
softwareProfile: response.softwareProfile ? response.softwareProfile['@id'] : null,
imageRepositories: response.imageRepositories ? response.imageRepositories.map((r: any) => r.imageRepository['@id']) : [],
});
this.imageId = response['@id'];
@ -85,18 +81,6 @@ export class CreateImageComponent implements OnInit {
this.showWarning = this.imageForm.get('isGlobal')?.value;
}
fetchSoftwareProfiles() {
const url = `${this.baseUrl}/software-profiles`;
this.http.get(url).subscribe({
next: (response: any) => {
this.softwareProfiles = response['hydra:member'];
},
error: (error) => {
this.toastService.error(this.translate.instant('errorGettingProfiles'));
}
});
}
fetchRepositories() {
const url = `${this.baseUrl}/image-repositories`;
this.http.get(url).subscribe({
@ -117,7 +101,6 @@ export class CreateImageComponent implements OnInit {
remotePc: this.imageForm.value.remotePc,
isGlobal: this.imageForm.value.isGlobal,
imageRepositories: this.imageForm.value.imageRepositories,
...(this.imageForm.value.softwareProfile ? { softwareProfile: this.imageForm.value.softwareProfile } : {}),
};
if (this.imageId) {

View File

@ -64,10 +64,14 @@ export class LoginComponent {
this.auth.refresh();
this.openSnackBar(false, 'Bienvenido ' + this.auth.username);
this.router.navigateByUrl('/groups');
this.dialog.open(GlobalStatusComponent, {
width: '65vw',
height: '80vh',
});
// El estado global consulta y sincroniza infraestructura (ogBoot, DHCP,
// repositorios), a la que solo tienen acceso los perfiles de administración.
if (this.auth.isSuperAdmin || this.auth.isOUAdmin) {
this.dialog.open(GlobalStatusComponent, {
width: '65vw',
height: '80vh',
});
}
}
this.isLoading = false;
},

View File

@ -110,6 +110,11 @@ export class ShowGitCommitsComponent implements OnInit{
if (this.repositories.length > 0) {
this.selectedRepository = this.repositories[0];
this.loadBranches();
} else {
this.selectedRepository = '';
this.branches = [];
this.selectedBranch = '';
this.dataSource.data = [];
}
},
error => {
@ -344,7 +349,7 @@ export class ShowGitCommitsComponent implements OnInit{
this.http.post(`${this.baseUrl}/image-repositories/server/git/${this.data.repositoryUuid}/delete`, payload).subscribe({
next: () => {
this.toastService.success(this.translate.instant('repositoryDeletedSuccessfully'));
this.dialogRef.close(true);
this.loadRepositories();
},
error: (error) => {
console.error(this.translate.instant('errorDeletingRepository1'), error);

View File

@ -108,3 +108,36 @@ table {
margin: 0 auto;
align-self: center;
}
.software-profile-select {
width: 150px;
font-size: 0.8em;
}
.software-profile-select ::ng-deep .mat-mdc-text-field-wrapper {
padding: 0 8px;
}
.software-profile-select ::ng-deep .mat-mdc-form-field-infix {
min-height: unset;
padding-top: 8px;
padding-bottom: 8px;
}
.software-profile-caret {
cursor: pointer;
color: rgba(0, 0, 0, 0.54);
font-size: 20px;
width: 20px;
height: 20px;
}
::ng-deep .software-profile-panel {
max-width: 320px;
}
::ng-deep .software-profile-panel .mat-mdc-option {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -83,8 +83,25 @@
{{ getStatusLabel(image[column.columnDef]) }}
</mat-chip>
</ng-container>
<ng-container *ngIf="column.columnDef === 'softwareProfile'">
<mat-form-field appearance="outline" class="software-profile-select" subscriptSizing="dynamic">
<input matInput #profileInput="matAutocompleteTrigger" [ngModel]="image.profileValue"
(ngModelChange)="image.profileValue = $event" [matAutocomplete]="profileAuto"
(blur)="resetProfileSearchIfInvalid(image)" [placeholder]="'noSoftwareProfileAssigned' | translate">
<mat-icon matSuffix class="software-profile-caret" (click)="profileInput.openPanel()">arrow_drop_down</mat-icon>
<mat-autocomplete #profileAuto="matAutocomplete" [displayWith]="displayProfile" panelWidth="auto"
class="software-profile-panel" (optionSelected)="onProfileSelected(image, $event.option.value)">
<mat-option [value]="null">{{ 'noSoftwareProfileAssigned' | translate }}</mat-option>
<mat-option *ngFor="let profile of filterProfiles(image.profileValue)" [value]="profile">
{{ profile.description }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</ng-container>
<ng-container
*ngIf="column.columnDef !== 'remotePc' && column.columnDef !== 'status' && column.columnDef !== 'isGlobal' && column.columnDef !== 'name' && column.columnDef !== 'datasize'">
*ngIf="column.columnDef !== 'remotePc' && column.columnDef !== 'status' && column.columnDef !== 'isGlobal' && column.columnDef !== 'name' && column.columnDef !== 'datasize' && column.columnDef !== 'softwareProfile'">
{{ column.cell(image) }}
</ng-container>
</td>

View File

@ -37,6 +37,7 @@ export class ShowMonoliticImagesComponent implements OnInit {
filters: { [key: string]: string } = {};
alertMessage: string | null = null;
repository: any = {};
softwareProfiles: any[] = [];
datePipe: DatePipe = new DatePipe('es-ES');
columns = [
{
@ -70,9 +71,9 @@ export class ShowMonoliticImagesComponent implements OnInit {
cell: (image: any) => this.formatSize(image.datasize)
},
{
columnDef: 'description',
header: 'labelDescription',
cell: (image: any) => image.description
columnDef: 'softwareProfile',
header: 'softwareProfile',
cell: (image: any) => image.softwareProfile ? image.softwareProfile['@id'] : null
},
{
columnDef: 'createdAt',
@ -100,13 +101,63 @@ export class ShowMonoliticImagesComponent implements OnInit {
if (this.data) {
this.loadData();
}
this.loadSoftwareProfiles();
}
loadSoftwareProfiles(): void {
this.http.get<any>(`${this.baseUrl}/software-profiles?page=1&itemsPerPage=10000`).subscribe({
next: (response) => {
this.softwareProfiles = response['hydra:member'];
},
error: (error) => {
console.error(this.translate.instant('errorGettingProfiles'), error);
}
});
}
filterProfiles(value: any): any[] {
const query = typeof value === 'string' ? value : (value?.description || '');
const normalized = query.toLowerCase();
return this.softwareProfiles.filter(profile => (profile.description || '').toLowerCase().includes(normalized));
}
displayProfile(value: any): string {
return value?.description || '';
}
onProfileSelected(image: any, selectedProfile: any | null): void {
const previousProfile = image.softwareProfile;
// Optimistic update: reflect the choice immediately so the blur/reset
// handler (which reads image.softwareProfile) doesn't race the PATCH
// response and briefly flash back to the old value.
image.softwareProfile = selectedProfile;
image.profileValue = selectedProfile;
this.http.patch(`${this.baseUrl}${image['@id']}`, { softwareProfile: selectedProfile ? selectedProfile['@id'] : null }).subscribe({
next: () => {
this.toastService.success(this.translate.instant('successfullyEditedSoftware'));
},
error: (error) => {
image.softwareProfile = previousProfile;
image.profileValue = previousProfile;
this.toastService.error(error.error['hydra:description']);
}
});
}
resetProfileSearchIfInvalid(image: any): void {
image.profileValue = image.softwareProfile || null;
}
loadData(): void {
this.loading = true;
this.http.get<any>(`${this.apiUrl}?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}&repository.id=${this.data.repositoryId}`, { params: this.filters }).subscribe(
data => {
this.dataSource.data = data['hydra:member'];
this.dataSource.data = (data['hydra:member'] || []).map((item: any) => ({
...item,
profileValue: item.softwareProfile || null
}));
this.length = data['hydra:totalItems'];
this.loading = false;
},

View File

@ -63,4 +63,28 @@
justify-content: flex-end;
gap: 1em;
padding: 0em 1.5em 1.5em 1.5em;
}
.software-tab-hint {
color: #666;
font-size: 0.85em;
margin: 0 0 12px 0;
}
.software-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
font-size: 0.85em;
color: #555;
}
.clear-selection-link {
background: none;
border: none;
color: #3f51b5;
cursor: pointer;
text-decoration: underline;
padding: 0;
}

View File

@ -12,14 +12,6 @@
<mat-label>{{ 'labelComments' | translate }}</mat-label>
<input matInput formControlName="comments" [placeholder]="'commandComments' | translate">
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectOrganizationalUnit' | translate }}</mat-label>
<mat-select formControlName="organizationalUnit" required>
<mat-option *ngFor="let ou of organizationalUnits" [value]="ou['@id']">
{{ ou.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectOperativeSystem' | translate }}</mat-label>
<mat-select formControlName="operativeSystem">
@ -33,11 +25,13 @@
<mat-tab label="Seleccionar Software">
<div class="form-group">
<p class="software-tab-hint">{{ 'selectSoftwareHint' | translate }}</p>
<mat-form-field appearance="fill" class="full-width">
<input type="text" matInput [formControl]="softwareControl" [matAutocomplete]="clientAuto"
[placeholder]="'selectSoftwareToAdd' | translate">
<mat-autocomplete #clientAuto="matAutocomplete" [displayWith]="displayFnClient"
<mat-autocomplete #clientAuto="matAutocomplete" [displayWith]="displayFnClient" autoActiveFirstOption
(optionSelected)="onOptionClientSelected($event.option.value)">
<mat-option *ngFor="let software of filteredSoftware | async" [value]="software">
@ -46,9 +40,17 @@
</mat-autocomplete>
</mat-form-field>
<div class="software-list-header">
<span>{{ 'selectedSoftwareCount' | translate: { count: selectedSoftwares.length } }}</span>
<button type="button" class="clear-selection-link" *ngIf="selectedSoftwares.length"
(click)="clearSelectedSoftwares()">
{{ 'clearAll' | translate }}
</button>
</div>
<div class="software-list">
<mat-list>
<mat-list-item *ngFor="let software of softwareCollection">
<mat-list-item *ngFor="let software of selectedSoftwares">
{{ software.name }}
<button mat-icon-button (click)="removeSoftware(software)">
<mat-icon>delete</mat-icon>

View File

@ -1,7 +1,7 @@
import { inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr';
@ -21,11 +21,8 @@ export class CreateSoftwareProfileComponent implements OnInit {
baseUrl: string;
private apiUrl: string;
formGroup: FormGroup;
softwareCollection: any[] = [];
organizationalUnits: any[] = [];
operativeSystems: any[] = [];
softwareProfileId: string | null = null;
selectedSoftware: any;
selectedSoftwares: any[] = [];
softwareControl = new FormControl();
filteredSoftware!: Observable<any[]>;
@ -45,7 +42,6 @@ export class CreateSoftwareProfileComponent implements OnInit {
this.formGroup = this.fb.group({
description: [''],
comments: [''],
organizationalUnit: [null, Validators.required],
operativeSystem: [null]
});
}
@ -54,10 +50,9 @@ export class CreateSoftwareProfileComponent implements OnInit {
if (this.data) {
this.softwareProfileId = this.data['@id'];
this.patchFormData();
this.loadSoftware();
}
this.loadSoftware();
this.loadOrganizationalUnits();
this.loadOperativeSystems();
this.filteredSoftware = this.softwareControl.valueChanges.pipe(
@ -71,15 +66,14 @@ export class CreateSoftwareProfileComponent implements OnInit {
this.formGroup.patchValue({
description: this.data.description || '',
comments: this.data.comments || '',
organizationalUnit: this.data.organizationalUnit ? this.data.organizationalUnit['@id'] : null,
operativeSystem: this.data.operativeSystem ? this.data.operativeSystem['@id'] : null,
});
}
loadSoftware() {
this.http.get<any>(`${this.baseUrl}/software?softwareProfileId=${this.data.id}&page=1&itemsPerPage=10`).subscribe(
this.http.get<any>(`${this.baseUrl}/software?softwareProfileId=${this.data.id}&page=1&itemsPerPage=1000`).subscribe(
response => {
this.softwareCollection = response['hydra:member'];
this.selectedSoftwares = response['hydra:member'];
},
error => {
console.error(this.translate.instant('softwareFetchingError'), error);
@ -87,17 +81,6 @@ export class CreateSoftwareProfileComponent implements OnInit {
);
}
loadOrganizationalUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=10000`).subscribe(
response => {
this.organizationalUnits = response['hydra:member'];
},
error => {
console.error(this.translate.instant('errorFetchingOrganizationalUnits1'), error);
}
);
}
loadOperativeSystems() {
this.http.get<any>(`${this.baseUrl}/operative-systems?page=1&itemsPerPage=10000`).subscribe(
response => {
@ -110,33 +93,36 @@ export class CreateSoftwareProfileComponent implements OnInit {
}
private _filterSoftware(value: string): Observable<any[]> {
return this.softwareDataService.getSoftwareCollection({ name: value }).pipe(
map(response => response || [])
const filters = { name: value || '', itemsPerPage: '15' };
return this.softwareDataService.getSoftwareCollection(filters).pipe(
map(response => (response || []).filter(software => !this.isSelected(software)))
);
}
isSelected(software: any): boolean {
return this.selectedSoftwares.some(s => s.id === software.id);
}
displayFnClient(client: any): string {
return client && client.name ? client.name : '';
}
onOptionClientSelected(software: any) {
if (!this.selectedSoftwares.find(s => s.id === software.id)) {
if (!this.isSelected(software)) {
this.selectedSoftwares.push(software);
}
this.softwareControl.setValue('');
}
addSoftware() {
const software = this.softwareCollection.find(s => s.id === this.selectedSoftware);
if (software && !this.selectedSoftwares.includes(software)) {
this.selectedSoftwares.push(software);
}
}
removeSoftware(software: any) {
this.selectedSoftwares = this.selectedSoftwares.filter(s => s.id !== software.id);
}
clearSelectedSoftwares(): void {
this.selectedSoftwares = [];
}
onCancel(event: Event): void {
event.preventDefault();
this.dialogRef.close();
@ -148,7 +134,6 @@ export class CreateSoftwareProfileComponent implements OnInit {
description: this.formGroup.value.description,
comments: this.formGroup.value.comments,
softwareCollection: this.selectedSoftwares.map(s => s['@id']),
organizationalUnit: this.formGroup.value.organizationalUnit,
operativeSystem: this.formGroup.value.operativeSystem
};

View File

@ -105,13 +105,13 @@
<ng-container *ngSwitchCase="'executedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.executedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span style="font-size: 0.8rem;"> {{ trace.executedAt |date: 'dd/MM/yyyy HH:mm:ss'}}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'finishedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.finishedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span style="font-size: 0.8rem;"> {{ trace.finishedAt |date: 'dd/MM/yyyy HH:mm:ss'}}</span>
</div>
</ng-container>
<ng-container *ngSwitchDefault>

View File

@ -53,12 +53,12 @@ export class ClientPendingTasksComponent implements OnInit {
{
columnDef: 'executedAt',
header: 'execution',
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy HH:mm:ss'),
},
{
columnDef: 'finishedAt',
header: 'ending',
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy HH:mm:ss'),
},
];
displayedColumns = [...this.columns.map(column => column.columnDef), 'information'];
@ -105,19 +105,19 @@ export class ClientPendingTasksComponent implements OnInit {
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data && data['@id']) {
this.updateTracesStatus(data['@id'], data.status);
this.updateTraceFromEvent(data['@id'], data);
}
}
}
private updateTracesStatus(clientUuid: string, newStatus: string): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === clientUuid);
private updateTraceFromEvent(traceId: string, data: any): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === traceId);
if (traceIndex !== -1) {
const updatedTraces = [...this.traces];
updatedTraces[traceIndex] = {
...updatedTraces[traceIndex],
status: newStatus
...data
};
this.traces = updatedTraces;
@ -240,7 +240,7 @@ export class ClientPendingTasksComponent implements OnInit {
openOutputModal(outputData: any): void {
this.dialog.open(OutputDialogComponent, {
width: '500px',
width: '70vw',
data: { input: outputData }
});
}

View File

@ -137,13 +137,13 @@
<ng-container *ngSwitchCase="'executedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.executedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span style="font-size: 0.8rem;"> {{ trace.executedAt |date: 'dd/MM/yyyy HH:mm:ss'}}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'finishedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.finishedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span style="font-size: 0.8rem;"> {{ trace.finishedAt |date: 'dd/MM/yyyy HH:mm:ss'}}</span>
</div>
</ng-container>
<ng-container *ngSwitchDefault>

View File

@ -65,12 +65,12 @@ export class ClientTaskLogsComponent implements OnInit {
{
columnDef: 'executedAt',
header: 'execution',
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy HH:mm:ss'),
},
{
columnDef: 'finishedAt',
header: 'ending',
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy HH:mm:ss'),
},
];
displayedColumns = [...this.columns.map(column => column.columnDef), 'information'];
@ -107,28 +107,23 @@ export class ClientTaskLogsComponent implements OnInit {
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data && data['@id']) {
this.updateTracesStatus(data['@id'], data.status, data.progress);
this.updateTraceFromEvent(data['@id'], data);
}
}
}
private updateTracesStatus(clientUuid: string, newStatus: string, progress: Number): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === clientUuid);
private updateTraceFromEvent(traceId: string, data: any): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === traceId);
if (traceIndex !== -1) {
const updatedTraces = [...this.traces];
updatedTraces[traceIndex] = {
...updatedTraces[traceIndex],
status: newStatus,
progress: progress
...data
};
this.traces = updatedTraces;
this.cdr.detectChanges();
console.log(`Estado actualizado para la traza ${clientUuid}: ${newStatus}`);
} else {
console.warn(`Traza con UUID ${clientUuid} no encontrado en la lista.`);
}
}
@ -172,7 +167,7 @@ export class ClientTaskLogsComponent implements OnInit {
openOutputModal(outputData: any): void {
this.dialog.open(OutputDialogComponent, {
width: '500px',
width: '70vw',
data: { input: outputData }
});
}

View File

@ -1,6 +1,10 @@
<h1 mat-dialog-title>{{ 'inputDetails' | translate }}</h1>
<div mat-dialog-content>
<pre>{{ data.input | json }}</pre>
@if (isString) {
<pre>{{ data.input }}</pre>
} @else {
<pre>{{ data.input | json }}</pre>
}
</div>
<div mat-dialog-actions align="end">
<button class="ordinary-button" (click)="close()">{{ 'buttonClose' | translate }}</button>

View File

@ -15,4 +15,8 @@ export class OutputDialogComponent {
close(): void {
this.dialogRef.close();
}
get isString(): boolean {
return typeof this.data.input === 'string';
}
}

View File

@ -224,6 +224,11 @@
<span>{{ 'showingResults' | translate: { from: getPaginationFrom(), to: getPaginationTo(), total: getPaginationTotal() } }}</span>
</div>
<div class="table-actions">
<button class="action-button" [disabled]="selection.selected.length === 0" (click)="cancelSelectedTraces()"
matTooltip="{{ 'cancelSelectedTraces' | translate }}">
<mat-icon>cancel</mat-icon>
{{ 'cancelSelectedTraces' | translate }}{{ selection.selected.length > 0 ? ' (' + selection.selected.length + ')' : '' }}
</button>
<button mat-icon-button (click)="refreshData()" matTooltip="{{ 'buttonUpdate' | translate }}">
<mat-icon>refresh</mat-icon>
</button>
@ -231,6 +236,22 @@
</div>
<table mat-table [dataSource]="traces" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
[disabled]="cancellableTraces.length === 0">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let trace">
<mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(trace)"
[checked]="selection.isSelected(trace)"
[disabled]="!isCancellable(trace)">
</mat-checkbox>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
@ -316,14 +337,14 @@
<ng-container *ngSwitchCase="'executedAt'">
<div class="date-cell">
<span class="date-time">{{ trace.executedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span class="date-time">{{ trace.executedAt |date: 'dd/MM/yyyy HH:mm:ss'}}</span>
<span class="date-relative" *ngIf="getRelativeTime(trace.executedAt)">{{ getRelativeTime(trace.executedAt) }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'finishedAt'">
<div class="date-cell">
<span class="date-time">{{ trace.finishedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span class="date-time">{{ trace.finishedAt |date: 'dd/MM/yyyy HH:mm:ss'}}</span>
<span class="date-relative" *ngIf="getRelativeTime(trace.finishedAt)">{{ getRelativeTime(trace.finishedAt) }}</span>
</div>
</ng-container>

View File

@ -8,6 +8,7 @@ import { map, startWith } from 'rxjs/operators';
import { DatePipe } from '@angular/common';
import { JoyrideService } from 'ngx-joyride';
import { MatDialog } from "@angular/material/dialog";
import { SelectionModel } from "@angular/cdk/collections";
import { InputDialogComponent } from "./input-dialog/input-dialog.component";
import { ProgressBarMode } from '@angular/material/progress-bar';
import { DeleteModalComponent } from "../../shared/delete_modal/delete-modal/delete-modal.component";
@ -101,17 +102,18 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
{
columnDef: 'executedAt',
header: 'execution',
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy HH:mm:ss'),
sortable: true
},
{
columnDef: 'finishedAt',
header: 'ending',
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy HH:mm:ss'),
sortable: true
},
];
displayedColumns = [...this.columns.map(column => column.columnDef), 'information'];
displayedColumns = ['select', ...this.columns.map(column => column.columnDef), 'information'];
selection = new SelectionModel<any>(true, []);
filters: { [key: string]: string } = {};
filteredClients!: Observable<any[]>;
@ -155,7 +157,7 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data && data['@id']) {
this.updateTracesStatus(data['@id'], data.status, data.progress);
this.updateTraceFromEvent(data['@id'], data);
}
}
}
@ -179,6 +181,31 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
this.selectedTrace = trace;
}
isCancellable(trace: any): boolean {
return trace.status === 'pending' || trace.status === 'in-progress';
}
get cancellableTraces(): any[] {
return this.traces.filter(trace => this.isCancellable(trace));
}
isAllSelected(): boolean {
return this.selection.selected.length === this.cancellableTraces.length && this.cancellableTraces.length > 0;
}
toggleAllRows(): void {
if (this.isAllSelected()) {
this.selection.clear();
} else {
this.selection.clear();
this.selection.select(...this.cancellableTraces);
}
}
toggleRow(row: any): void {
this.selection.toggle(row);
}
sortColumn(columnDef: string): void {
if (this.currentSortColumn === columnDef) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
@ -258,8 +285,8 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
this.translateCommand(trace.command),
trace.client?.name || '',
trace.status,
this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy HH:mm:ss'),
this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy HH:mm:ss'),
trace.jobId || ''
]);
@ -328,6 +355,55 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
});
}
cancelSelectedTraces(): void {
const selectedTraces = this.selection.selected.filter(trace => this.isCancellable(trace));
if (selectedTraces.length === 0) {
this.toastService.warning(this.translate.instant('noTracesSelectedToCancel'), 'Advertencia');
return;
}
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '400px',
data: {
title: this.translate.instant('cancelSelectedTraces'),
message: this.translate.instant('confirmCancelSelectedTraces', { count: selectedTraces.length }),
confirmText: 'Cancelar',
cancelText: 'No cancelar'
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.http.post<any>(`${this.baseUrl}/traces/cancel-multiple`, {
traces: selectedTraces.map(trace => trace['@id'])
}).subscribe(
(response) => {
const cancelledCount = response?.cancelled?.length ?? selectedTraces.length;
const failedCount = response?.failed?.length ?? 0;
if (failedCount > 0) {
this.toastService.warning(
this.translate.instant('selectedTracesCancelledSummary', { cancelled: cancelledCount, failed: failedCount }),
'Advertencia'
);
} else {
this.toastService.success(this.translate.instant('traceCanceledSuccessfully'), 'Éxito');
}
this.selection.clear();
this.loadTraces();
this.loadTotalStats();
},
(error) => {
console.error(this.translate.instant('errorCancelingSelectedTraces'), error);
this.toastService.error(this.translate.instant('errorCancelingSelectedTraces'), 'Error');
}
);
}
});
}
markTraceAsSuccess(trace: any): void {
if (trace.status !== 'in-progress') {
this.toastService.warning(this.translate.instant('canOnlyBeMarkedAs'), 'Advertencia');
@ -363,15 +439,14 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
});
}
private updateTracesStatus(clientUuid: string, newStatus: string, progress: Number): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === clientUuid);
private updateTraceFromEvent(traceId: string, data: any): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === traceId);
if (traceIndex !== -1) {
const updatedTraces = [...this.traces];
updatedTraces[traceIndex] = {
...updatedTraces[traceIndex],
status: newStatus,
progress: progress
...data
};
this.traces = updatedTraces;
@ -423,7 +498,7 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
openOutputModal(outputData: any): void {
this.dialog.open(OutputDialogComponent, {
width: '500px',
width: '70vw',
data: { input: outputData }
});
}
@ -471,6 +546,7 @@ export class TaskLogsComponent implements OnInit, OnDestroy {
this.traces = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.groupedTraces = this.groupByCommandId(this.traces);
this.selection.clear();
this.loading = false;
if (Object.keys(this.filters).length === 0) {
this.loadTotalStats();

View File

@ -139,7 +139,6 @@ describe('roleGuard — control de acceso por rol', () => {
// super-admin + ou-admin
{ path: 'commands', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'commands-task', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'commands-logs', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'pxe-images', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'pxe', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'pxe-boot-file', allowedRoles: ['super-admin', 'ou-admin'] },
@ -156,6 +155,7 @@ describe('roleGuard — control de acceso por rol', () => {
{ path: 'hardware-profiles', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'hardware-types', allowedRoles: ['super-admin', 'ou-admin'] },
// super-admin + ou-admin + ou-operator
{ path: 'commands-logs', allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },
{ path: 'clients/deploy-image', allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },
{ path: 'clients/partition-assistant',allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },
{ path: 'clients/run-script', allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },

View File

@ -21,9 +21,11 @@
{{ 'downloadAgent' | translate }}
</a>
<button class="ordinary-button" (click)="showGlobalStatus()">
{{ 'GlobalStatus' | translate }}
</button>
@if (auth.userCategory === 'super-admin' || auth.userCategory === 'ou-admin') {
<button class="ordinary-button" (click)="showGlobalStatus()">
{{ 'GlobalStatus' | translate }}
</button>
}
@if (auth.userCategory === 'super-admin') {
<button class="ordinary-button" [matMenuTriggerFor]="menu"
@ -75,9 +77,11 @@
<mat-icon>download</mat-icon>
{{ 'downloadAgent' | translate }}
</a>
<button mat-menu-item (click)="showGlobalStatus()">
{{ 'GlobalStatus' | translate }}
</button>
@if (auth.userCategory === 'super-admin' || auth.userCategory === 'ou-admin') {
<button mat-menu-item (click)="showGlobalStatus()">
{{ 'GlobalStatus' | translate }}
</button>
}
@if (auth.userCategory === 'super-admin') {
<button mat-menu-item [matMenuTriggerFor]="menu">
{{ 'Administration' | translate }}

View File

@ -34,7 +34,14 @@ export class HeaderComponent implements OnInit {
ngOnInit(): void {
this.breakpointObserver.observe(['(max-width: 576px)']).subscribe((result) => {
this.isSmallScreen = result.matches;
})
});
// Aplicar idioma guardado: el del JWT (servidor) tiene prioridad sobre localStorage
const jwtLang = this.auth.language;
const localLang = localStorage.getItem('language');
const langToApply = jwtLang || localLang || 'es';
this.translate.use(langToApply);
localStorage.setItem('language', langToApply);
}
get availableLanguages() {

View File

@ -18,7 +18,7 @@
</mat-list-item>
<mat-list-item (click)="toggleCommandSub()" matTooltip="{{ 'TOOLTIP_ACTIONS' | translate }}"
matTooltipShowDelay="1000" *ngIf="auth.userCategory !== 'ou-operator' && auth.userCategory !== 'ou-minimal'">
matTooltipShowDelay="1000" *ngIf="auth.userCategory !== 'ou-minimal'">
<span class="entry">
<mat-icon class="icon">playlist_play</mat-icon>
<span>{{ 'actions' | translate }}</span>
@ -51,7 +51,7 @@
</span>
</mat-list-item>
<mat-list-item routerLink="/commands-logs" (click)="onItemClick()" matTooltip="{{ 'TOOLTIP_TRACE_LOGS' | translate }}"
matTooltipShowDelay="1000" *ngIf="auth.userCategory !== 'ou-operator' && auth.userCategory !== 'ou-minimal'">
matTooltipShowDelay="1000" *ngIf="auth.userCategory !== 'ou-minimal'">
<span class="entry">
<mat-icon class="icon">chevron_right</mat-icon>
<span>{{ 'traceLogs' | translate }}</span>

View File

@ -116,8 +116,8 @@ describe('SidebarComponent — visibilidad de elementos por rol', () => {
});
});
// ── Ou-operator: solo /groups ──────────────────────────────────────────────
describe('ou-operator — solo ve /groups', () => {
// ── Ou-operator: /groups y las trazas de sus acciones ──────────────────────
describe('ou-operator — ve /groups y /commands-logs', () => {
beforeEach(async () => createComponent('ou-operator'));
it('ve /groups', () => {
@ -130,9 +130,22 @@ describe('SidebarComponent — visibilidad de elementos por rol', () => {
});
});
it('NO ve el menú de acciones (commands)', () => {
// Solo debe haber 1 mat-list-item visible: /groups
expect(countVisibleListItems(fixture)).toBe(1);
it('ve el menú de acciones (commands) para acceder a las trazas', () => {
// /groups + el desplegable de acciones
expect(countVisibleListItems(fixture)).toBe(2);
});
it('ve el enlace a /commands-logs al desplegar el menú de acciones', () => {
component.toggleCommandSub();
fixture.detectChanges();
expect(hasRouteLink(fixture, '/commands-logs')).toBeTrue();
});
it('NO ve los enlaces de gestión de comandos ni tareas', () => {
component.toggleCommandSub();
fixture.detectChanges();
expect(hasRouteLink(fixture, '/commands')).toBeFalse();
expect(hasRouteLink(fixture, '/commands-task')).toBeFalse();
});
});

View File

@ -18,6 +18,7 @@ interface JwtPayload {
uuid: string;
groupsView: string;
clientsPerPage: number | null;
language: string | null;
}
@Injectable({ providedIn: 'root' })
@ -103,6 +104,11 @@ export class AuthService {
return this.tokenPayload?.clientsPerPage ?? null;
}
/** Idioma preferido del usuario */
get language(): string | null {
return this.tokenPayload?.language ?? null;
}
/**
* Devuelve el rol principal del usuario según prioridad.
*/
@ -122,6 +128,7 @@ export class AuthService {
localStorage.removeItem('username');
localStorage.removeItem('groupsView');
localStorage.removeItem('language');
localStorage.removeItem('lastSelectedOU');
this.tokenPayload = null;
if (options.redirect && this.router.url !== '/auth/login') {
this.router.navigate(['/auth/login']);

View File

@ -65,6 +65,7 @@
"advancedParameters": "Paràmetres avançats",
"all": "Tots",
"allowedOrganizationalUnits": "Unitats Organitzacionals Permeses",
"andMoreClients": "i {{count}} més",
"anErrorOccurredWhileLoading": "S'ha produït un error en carregar les unitats organitzatives",
"apiPort": "Port API",
"application": "Aplicació",
@ -152,7 +153,7 @@
"buttonSelectIso": "Seleccionar ISO",
"buttonSelectOption": "Seleccionar opció",
"buttonSelectPartition": "Seleccionar partició",
"buttonSelectRepository": "Seleccionar Repositori",
"buttonSelectRepository": "Seleccionar Repositori Git",
"buttonSync": "Sincronitzar",
"buttonUpdate": "Actualitzar",
"buttonUpdateData": "Actualitzar dades",
@ -176,6 +177,7 @@
"cancelAllDisplayedActions": "Cancel·lar totes les accions mostrades",
"cancelImageTransmission": "Cancel·lar transmissió d'imatge",
"cancelled": "Cancel·lat",
"cancelSelectedTraces": "Cancel·lar seleccionades",
"cancelTrace": "Cancel·lant traça",
"cannotAccess": "No es pot accedir als registres: IP del client no disponible",
"cannotConnectBootServer": "No s'ha pogut connectar amb el servidor OgBoot seleccionat",
@ -211,6 +213,7 @@
"classroomStatusUpdated": "Estat de l'aula actualitzat",
"classroomTitle": "Aula",
"classroomWillBe": "L'aula estarà",
"clearAll": "Treure tots",
"clearClientSearch": "Netejar cerca de client",
"clearInputSearch": "Netejar cerca",
"clearTreeSearch": "Netejar cerca a l'arbre",
@ -235,9 +238,12 @@
"clientOff": "Client apagat",
"clientOgLive": "Client OGLive",
"clientPropertiesTitle": "Propietats del client",
"clients": "Clients",
"clientSelectStepText": "Selecciona un client per veure les traces associades.",
"clientsPerPage": "Clients per pàgina",
"clientsPerPageHint": "Nombre de clients mostrats per pàgina al llistat",
"userLanguage": "Idioma preferit",
"userLanguageHint": "Idioma de la interfície per a aquest usuari",
"clientSuccessfullyRemovedFromThe": "Client eliminat de la xarxa correctament",
"clientsViewStepText": "Visualització dels clients de la unitat organitzativa seleccionada",
"clientUpdatedSuccessfully": "Client actualitzat correctament",
@ -283,6 +289,7 @@
"configuredParameters": "(paràmetres configurats)",
"configureRequiredParameters": "Configuri els valors per als paràmetres requerits",
"confirmationTitle": "Confirmació",
"confirmCancelSelectedTraces": "Segur que vols cancel·lar {{count}} traça/traces seleccionada(es)?",
"confirmServerMigration": "Confirmar migració de servidors",
"connectionError": "Error de connexió",
"convertImageButton": "Convertir imatge virtual",
@ -422,6 +429,7 @@
"errorCancelingActions1": "Error en cancel·lar les accions:",
"errorCancelingInprogressTrace": "Error en cancel·lar la traça en progrés:",
"errorCancelingPendingTrace": "Error en cancel·lar la traça pendent:",
"errorCancelingSelectedTraces": "Error en cancel·lar les traces seleccionades",
"errorCancelingTrace": "Error en cancel·lar la traça",
"errorChangingPassword": "Error en canviar la contrasenya:",
"errorCheckingStatus": "Error en comprovar l'estat de l'aula",
@ -454,6 +462,7 @@
"errorEditingRole1": "Error en editar el rol:",
"errorEditingSystem": "Error en editar el sistema operatiu",
"errorExecutingGroupOfCommands": "Error executant el grup de comandaments:",
"errorExecutingTaskNow": "Error en executar la tasca",
"errorFetchingAvailableCommands": "Error en obtenir els comandaments disponibles",
"errorFetchingBranchesForGit": "Error en obtenir les branques per a les dades de Git",
"errorFetchingCalendars": "Error en obtenir els calendaris",
@ -584,11 +593,13 @@
"hardwareInventory": "Inventari de maquinari",
"runScript": "Executar script",
"refresh": "Refrescar",
"deleteLease": "Eliminar concessió DHCP"
"deleteLease": "Eliminar concessió DHCP",
"executeTask": "Executar tasca"
},
"executeCommandStepText": "Conjunt d'accions a executar sobre una selecció de clients o unitat organitzativa",
"executeCommandTitle": "Executar Comandament",
"executeCommandTooltip": "Executar comandament en aquest element",
"executeTaskNowModalTitle": "Executar tasca ara",
"execution": "Execució",
"executionDate": "Data d'execució",
"executionDateTimeSectionTitle": "Data i hora d'execució",
@ -1015,6 +1026,7 @@
"noFilesInDirectory": "No hi ha fitxers en aquest directori",
"noGitInfoForPartition": "No s'ha trobat informació de Git per a aquesta partició",
"noHardwareAssociatedToProfile": "No hi ha maquinari associat a aquest perfil",
"noHardwareProfileAssigned": "Aquest client no té cap perfil de maquinari assignat",
"noInternalElementsMessage": "No hi ha elements interns",
"noLogsAvailable": "noLogsAvailable",
"noOgLivesInServer": "No hi ha OgLives en aquest servidor",
@ -1027,14 +1039,17 @@
"noReposAvailableCreateNew": "No hi ha repositoris disponibles. Crea un de nou per continuar.",
"noResultsMessage": "No hi ha resultats per mostrar.",
"noSavedScripts": "No hi ha scripts desats disponibles",
"noSoftwareProfileAssigned": "Sense perfil de programari assignat",
"notAccessible": "No accessible",
"noTags": "Sense etiquetes",
"noTasksAvailableMessage": "No hi ha tasques disponibles",
"notAvailableLowercase": "no disponible",
"notCalculated": "No calculada",
"notDefined": "No definit",
"noTemplateOption": "Cap",
"noTemplatesInServer": "No hi ha plantilles en aquest servidor",
"notEnoughFreeSpace": "No hi ha suficient espai lliure al disc per crear una nova partició.",
"noTracesSelectedToCancel": "No hi ha traces seleccionades per cancel·lar",
"notesPlaceholder": "Ingressa les teves notes aquí",
"notValidDataFound": "No s'han trobat dades vàlides",
"ntfsOption": "NTFS",
@ -1264,6 +1279,8 @@
"selectedClients": "Clients seleccionats",
"selectedClientsTitle": "Clients seleccionats:",
"selectedCommandsTitle": "Comandaments Seleccionats",
"selectedSoftwareCount": "{{count}} programari afegit",
"selectedTracesCancelledSummary": "{{cancelled}} cancel·lada(es), {{failed}} no s'han pogut cancel·lar",
"selectGitRepoHint": "Selecciona el repositori git per obtenir les imatges disponibles.",
"selectGitRepoOs": "Seleccionar repositori git / SO",
"selectGitRepository": "Seleccionar repositori Git",
@ -1280,10 +1297,11 @@
"selectParentOuDhcpHint": "Seleccioni una Unitat Organitzativa Pare (OU) on agrupar les subxarxes i clients que es detectin automàticament en el servei DHCP.",
"selectPartitionToBootOs": "Seleccionar partició per arrencar SO",
"selectPartitionToInventory": "Seleccionar partició per inventariar",
"selectRepository": "Seleccionar repositori",
"selectRepository": "Seleccionar Repositori OG",
"selectRepoToView": "Tria un repositori de la llista per veure el seu estat detallat.",
"selectScriptToRun": "Seleccioni script a executar",
"selectScriptToView": "Tria un script desat de la llista per veure el seu contingut i configurar paràmetres.",
"selectSoftwareHint": "Escriu per cercar i prem Retorn o fes clic per afegir-lo. El camp s'esborra automàticament perquè puguis continuar afegint programari sense interrupcions.",
"selectSoftwareToAdd": "Seleccioni programari per afegir",
"selectTask": "Seleccioni una tasca",
"selectTemplateDescription": "Seleccioni plantilla",
@ -1395,6 +1413,7 @@
"taskCreatedSuccessfully": "Tasca creada correctament",
"taskDeletedSuccessfully": "Tasca eliminada correctament",
"taskDetailsTitle": "Detalls de la Tasca",
"taskExecutedSuccessfully": "Tasca executada correctament",
"taskId": "ID de la Tasca",
"taskName": "Nom de la tasca",
"tasks": "Tasques",
@ -1503,6 +1522,7 @@
"viewInput": "Veure entrada",
"viewOutput": "Veure sortida",
"viewParameters": "Veure paràmetres",
"viewScript": "Veure script",
"viewPendingTraces": "Veure traces pendents",
"viewSentTraces": "Veure traces enviades",
"viewSubnets": "Veure Subxarxes",

View File

@ -65,6 +65,7 @@
"advancedParameters": "Advanced parameters",
"all": "All",
"allowedOrganizationalUnits": "Allowed Organizational Units",
"andMoreClients": "and {{count}} more",
"anErrorOccurredWhileLoading": "An error occurred while loading the organizational units",
"apiPort": "API Port",
"application": "Application",
@ -152,7 +153,7 @@
"buttonSelectIso": "Select ISO",
"buttonSelectOption": "Select option",
"buttonSelectPartition": "Select partition",
"buttonSelectRepository": "Select Repository",
"buttonSelectRepository": "Select Git Repository",
"buttonSync": "Sync",
"buttonUpdate": "Refresh",
"buttonUpdateData": "Update data",
@ -176,6 +177,7 @@
"cancelAllDisplayedActions": "Cancel all displayed actions",
"cancelImageTransmission": "Cancel image transmission",
"cancelled": "Cancelled",
"cancelSelectedTraces": "Cancel selected",
"cancelTrace": "Cancelling Trace",
"cannotAccess": "Cannot access logs: Client IP not available",
"cannotConnectBootServer": "Could not connect to the selected OgBoot server",
@ -211,6 +213,7 @@
"classroomStatusUpdated": "Classroom status updated",
"classroomTitle": "Classroom",
"classroomWillBe": "The classroom will be",
"clearAll": "Clear all",
"clearClientSearch": "Clear client search",
"clearInputSearch": "Clear input search",
"clearTreeSearch": "Clear tree search",
@ -235,9 +238,12 @@
"clientOff": "Client off",
"clientOgLive": "OGLive client",
"clientPropertiesTitle": "Client properties",
"clients": "Clients",
"clientSelectStepText": "Select a client to see the associated traces.",
"clientsPerPage": "Clients per page",
"clientsPerPageHint": "Number of clients shown per page in the list",
"userLanguage": "Preferred language",
"userLanguageHint": "Interface language for this user",
"clientSuccessfullyRemovedFromThe": "Client successfully removed from the network",
"clientsViewStepText": "Display of the selected organizational unit's clients",
"clientUpdatedSuccessfully": "Client updated successfully",
@ -283,6 +289,7 @@
"configuredParameters": "(configured parameters)",
"configureRequiredParameters": "Configure values for required parameters",
"confirmationTitle": "Confirmation",
"confirmCancelSelectedTraces": "Are you sure you want to cancel {{count}} selected trace(s)?",
"confirmServerMigration": "Confirm server migration",
"connectionError": "Connection error",
"convertImageButton": "Convert virtual image",
@ -422,6 +429,7 @@
"errorCancelingActions1": "Error canceling actions:",
"errorCancelingInprogressTrace": "Error cancelling in-progress trace:",
"errorCancelingPendingTrace": "Error cancelling pending trace:",
"errorCancelingSelectedTraces": "Error cancelling selected traces",
"errorCancelingTrace": "Error canceling trace",
"errorChangingPassword": "Error changing password:",
"errorCheckingStatus": "Error checking classroom status",
@ -454,6 +462,7 @@
"errorEditingRole1": "Error editing role:",
"errorEditingSystem": "Error editing operating system",
"errorExecutingGroupOfCommands": "Error executing command group:",
"errorExecutingTaskNow": "Error executing the task",
"errorFetchingAvailableCommands": "Error fetching available commands",
"errorFetchingBranchesForGit": "Error fetching branches for Git data",
"errorFetchingCalendars": "Error fetching calendars",
@ -584,11 +593,13 @@
"hardwareInventory": "Hardware Inventory",
"runScript": "Run Script",
"refresh": "Refresh",
"deleteLease": "Delete DHCP Lease"
"deleteLease": "Delete DHCP Lease",
"executeTask": "Run Task"
},
"executeCommandStepText": "Set of actions to be performed on a selection of clients or an organizational unit.",
"executeCommandTitle": "Execute Command",
"executeCommandTooltip": "Execute command on this element",
"executeTaskNowModalTitle": "Run Task Now",
"execution": "Execution",
"executionDate": "Execution date",
"executionDateTimeSectionTitle": "Execution date and time",
@ -1015,6 +1026,7 @@
"noFilesInDirectory": "There are no files in this directory",
"noGitInfoForPartition": "No Git information found for this partition",
"noHardwareAssociatedToProfile": "There is no hardware associated with this profile",
"noHardwareProfileAssigned": "This client does not have any hardware profile assigned",
"noInternalElementsMessage": "No internal elements",
"noLogsAvailable": "noLogsAvailable",
"noOgLivesInServer": "There are no OgLives on this server",
@ -1027,14 +1039,17 @@
"noReposAvailableCreateNew": "No repositories available. Create a new one to continue.",
"noResultsMessage": "No results to display.",
"noSavedScripts": "No saved scripts available",
"noSoftwareProfileAssigned": "No software profile assigned",
"notAccessible": "Not accessible",
"noTags": "No tags",
"noTasksAvailableMessage": "No tasks available",
"notAvailableLowercase": "not available",
"notCalculated": "Not calculated",
"notDefined": "Not defined",
"noTemplateOption": "None",
"noTemplatesInServer": "There are no templates on this server",
"notEnoughFreeSpace": "Not enough free space on the disk to create a new partition.",
"noTracesSelectedToCancel": "No traces selected to cancel",
"notesPlaceholder": "Enter your notes here",
"notValidDataFound": "No valid data found",
"ntfsOption": "NTFS",
@ -1264,6 +1279,8 @@
"selectedClients": "Selected clients",
"selectedClientsTitle": "Selected clients:",
"selectedCommandsTitle": "Selected Commands",
"selectedSoftwareCount": "{{count}} software added",
"selectedTracesCancelledSummary": "{{cancelled}} cancelled, {{failed}} could not be cancelled",
"selectGitRepoHint": "Select the git repository to get available images.",
"selectGitRepoOs": "Select git repository / OS",
"selectGitRepository": "Select Git repository",
@ -1280,10 +1297,11 @@
"selectParentOuDhcpHint": "Select a Parent Organizational Unit (OU) to group the subnets and clients automatically detected in the DHCP service.",
"selectPartitionToBootOs": "Select partition to boot OS",
"selectPartitionToInventory": "Select partition to inventory",
"selectRepository": "Select repository",
"selectRepository": "Select OG Repository",
"selectRepoToView": "Select a repository from the list to view its detailed status.",
"selectScriptToRun": "Select script to run",
"selectScriptToView": "Select a saved script from the list to view its content and configure parameters.",
"selectSoftwareHint": "Type to search and press Enter or click to add it. The field clears automatically so you can keep adding software without interruptions.",
"selectSoftwareToAdd": "Select software to add",
"selectTask": "Select a task",
"selectTemplateDescription": "Select template description",
@ -1395,6 +1413,7 @@
"taskCreatedSuccessfully": "Task created successfully",
"taskDeletedSuccessfully": "Task deleted successfully",
"taskDetailsTitle": "Task Details",
"taskExecutedSuccessfully": "Task executed successfully",
"taskId": "Task ID",
"taskName": "Task name",
"tasks": "Tasks",
@ -1503,6 +1522,7 @@
"viewInput": "View input",
"viewOutput": "View output",
"viewParameters": "View parameters",
"viewScript": "View script",
"viewPendingTraces": "View pending traces",
"viewSentTraces": "View sent traces",
"viewSubnets": "View Subnets",

View File

@ -65,6 +65,7 @@
"advancedParameters": "Parámetros avanzados",
"all": "Todos",
"allowedOrganizationalUnits": "Unidades Organizacionales Permitidas",
"andMoreClients": "y {{count}} más",
"anErrorOccurredWhileLoading": "Ocurrió un error al cargar las unidades organizativas",
"apiPort": "Puerto API",
"application": "Aplicacion",
@ -152,7 +153,7 @@
"buttonSelectIso": "Seleccionar ISO",
"buttonSelectOption": "Seleccionar opción",
"buttonSelectPartition": "Seleccionar partición",
"buttonSelectRepository": "Seleccionar Repositorio",
"buttonSelectRepository": "Seleccionar Repositorio Git",
"buttonSync": "Sincronizar",
"buttonUpdate": "Actualizar",
"buttonUpdateData": "Actualizar datos",
@ -176,6 +177,7 @@
"cancelAllDisplayedActions": "Cancelar todas las acciones mostradas",
"cancelImageTransmission": "Cancelar transmisión de imagen",
"cancelled": "Cancelado",
"cancelSelectedTraces": "Cancelar seleccionadas",
"cancelTrace": "Cancelando traza",
"cannotAccess": "No se puede acceder a los logs: IP del cliente no disponible",
"cannotConnectBootServer": "No se pudo conectar con el servidor OgBoot seleccionado",
@ -211,6 +213,7 @@
"classroomStatusUpdated": "Estado del aula actualizado",
"classroomTitle": "Aula",
"classroomWillBe": "El aula estará",
"clearAll": "Quitar todos",
"clearClientSearch": "Limpiar búsqueda de cliente",
"clearInputSearch": "Limpiar búsqueda",
"clearTreeSearch": "Limpiar búsqueda en el árbol",
@ -235,9 +238,12 @@
"clientOff": "Cliente apagado",
"clientOgLive": "Cliente OGLive",
"clientPropertiesTitle": "Propiedades del cliente",
"clients": "Clientes",
"clientSelectStepText": "Selecciona un cliente para ver las trazas asociadas.",
"clientsPerPage": "Clientes por página",
"clientsPerPageHint": "Número de clientes mostrados por página en el listado",
"userLanguage": "Idioma preferido",
"userLanguageHint": "Idioma de la interfaz para este usuario",
"clientSuccessfullyRemovedFromThe": "Cliente eliminado de la red con éxito",
"clientsViewStepText": "Visualización de los clientes de la unidad organizativa seleccionada",
"clientUpdatedSuccessfully": "Cliente actualizado con éxito",
@ -283,6 +289,7 @@
"configuredParameters": "(parámetros configurados)",
"configureRequiredParameters": "Configure los valores para los parámetros requeridos",
"confirmationTitle": "Confirmación",
"confirmCancelSelectedTraces": "¿Estás seguro de que quieres cancelar {{count}} traza(s) seleccionada(s)?",
"confirmServerMigration": "Confirmar migración de servidores",
"connectionError": "Error de conexión",
"convertImageButton": "Convertir imagen virtual",
@ -422,6 +429,7 @@
"errorCancelingActions1": "Error al cancelar las acciones:",
"errorCancelingInprogressTrace": "Error al cancelar la traza en progreso:",
"errorCancelingPendingTrace": "Error al cancelar la traza pendiente:",
"errorCancelingSelectedTraces": "Error al cancelar las trazas seleccionadas",
"errorCancelingTrace": "Error al cancelar la traza",
"errorChangingPassword": "Error al cambiar la contraseña:",
"errorCheckingStatus": "Error al comprobar el estado del aula",
@ -454,6 +462,7 @@
"errorEditingRole1": "Error al editar el rol:",
"errorEditingSystem": "Error al editar el sistema operativo",
"errorExecutingGroupOfCommands": "Error ejecutando el grupo de comandos:",
"errorExecutingTaskNow": "Error al ejecutar la tarea",
"errorFetchingAvailableCommands": "Error al obtener los comandos disponibles",
"errorFetchingBranchesForGit": "Error al obtener las ramas para los datos de Git",
"errorFetchingCalendars": "Error al obtener los calendarios",
@ -584,11 +593,13 @@
"hardwareInventory": "Inventario de hardware",
"runScript": "Ejecutar script",
"refresh": "Refrescar",
"deleteLease": "Eliminar concesión DHCP"
"deleteLease": "Eliminar concesión DHCP",
"executeTask": "Ejecutar tarea"
},
"executeCommandStepText": "Conjunto de acciones a ejecutar sobre una selección de clientes o unidad organizativa",
"executeCommandTitle": "Ejecutar Comando",
"executeCommandTooltip": "Ejecutar comando en este elemento",
"executeTaskNowModalTitle": "Ejecutar tarea ahora",
"execution": "Ejecución",
"executionDate": "Fecha de ejecución",
"executionDateTimeSectionTitle": "Fecha y hora de ejecución",
@ -1015,6 +1026,7 @@
"noFilesInDirectory": "No hay archivos en este directorio",
"noGitInfoForPartition": "No se encontró información de Git para esta partición",
"noHardwareAssociatedToProfile": "No hay hardware asociado a este perfil",
"noHardwareProfileAssigned": "Este cliente no tiene ningún perfil de hardware asignado",
"noInternalElementsMessage": "No hay elementos internos",
"noLogsAvailable": "noLogsAvailable",
"noOgLivesInServer": "No hay OgLives en este servidor",
@ -1027,14 +1039,17 @@
"noReposAvailableCreateNew": "No hay repositorios disponibles. Crea uno nuevo para continuar.",
"noResultsMessage": "No hay resultados para mostrar.",
"noSavedScripts": "No hay scripts guardados disponibles",
"noSoftwareProfileAssigned": "Sin perfil de software asignado",
"notAccessible": "No accesible",
"noTags": "Sin tags",
"noTasksAvailableMessage": "No hay tareas disponibles",
"notAvailableLowercase": "no disponible",
"notCalculated": "No calculada",
"notDefined": "No definido",
"noTemplateOption": "Ninguna",
"noTemplatesInServer": "No hay plantillas en este servidor",
"notEnoughFreeSpace": "No hay suficiente espacio libre en el disco para crear una nueva partición.",
"noTracesSelectedToCancel": "No hay trazas seleccionadas para cancelar",
"notesPlaceholder": "Ingresa tus notas aquí",
"notValidDataFound": "No se encontraron datos válidos",
"ntfsOption": "NTFS",
@ -1264,6 +1279,8 @@
"selectedClients": "Clientes seleccionados",
"selectedClientsTitle": "Clientes seleccionados:",
"selectedCommandsTitle": "Comandos Seleccionados",
"selectedSoftwareCount": "{{count}} programas añadidos",
"selectedTracesCancelledSummary": "{{cancelled}} cancelada(s), {{failed}} no se pudieron cancelar",
"selectGitRepoHint": "Selecciona el repositorio git para obtener las imágenes disponibles.",
"selectGitRepoOs": "Seleccionar repositorio git / SO",
"selectGitRepository": "Seleccionar repositorio Git",
@ -1280,10 +1297,11 @@
"selectParentOuDhcpHint": "Seleccione una Unidad Organizativa Padre (OU) donde agrupar las subredes y clientes que se detecten automáticamente en el servicio DHCP.",
"selectPartitionToBootOs": "Seleccionar particion para arrancar SO",
"selectPartitionToInventory": "Seleccionar partición para inventariar",
"selectRepository": "Seleccionar repositorio",
"selectRepository": "Seleccionar Repositorio OG",
"selectRepoToView": "Elige un repositorio de la lista para ver su estado detallado.",
"selectScriptToRun": "Seleccione script a ejecutar",
"selectScriptToView": "Elige un script guardado de la lista para ver su contenido y configurar parámetros.",
"selectSoftwareHint": "Escribe para buscar y pulsa Enter o haz clic para añadir. El campo se vacía automáticamente para que puedas seguir añadiendo software sin interrupciones.",
"selectSoftwareToAdd": "Seleccione software para añadir",
"selectTask": "Seleccione una tarea",
"selectTemplateDescription": "Seleccione plantilla",
@ -1395,6 +1413,7 @@
"taskCreatedSuccessfully": "Tarea creada con éxito",
"taskDeletedSuccessfully": "Tarea eliminada con éxito",
"taskDetailsTitle": "Detalles de la Tarea",
"taskExecutedSuccessfully": "Tarea ejecutada correctamente",
"taskId": "ID de la Tarea",
"taskName": "Nombre de la tarea",
"tasks": "Tareas",
@ -1503,6 +1522,7 @@
"viewInput": "Ver entrada",
"viewOutput": "Ver salida",
"viewParameters": "Ver parámetros",
"viewScript": "Ver script",
"viewPendingTraces": "Ver trazas pendientes",
"viewSentTraces": "Ver trazas enviadas",
"viewSubnets": "Ver Subredes",