develop #41

Merged
maranda merged 6 commits from develop into main 2025-09-05 12:17:43 +02:00
7 changed files with 551 additions and 58 deletions

View File

@ -1,4 +1,13 @@
# Changelog
## [0.22.2] - 2025-09-05
### Improved
- Se ha mejorado la UX en el asistente de ejecurcion de scripts.
### Fixed
- Se ha corregido en el particionador, el que el tamaño de las particiones EFI no esten fijas a 512 cuando ya haya datos almacenados.
- Se ha corregido un bug que hacia que no pasara los clientes seleccionados en el asistente de script, y en las tareas programadas.
---
## [0.22.1] - 2025-09-05
### Improved
- Se ha mejorado la experiencia de usuario con el despleable de "tipos de particion" en el asistente de particonado.

View File

@ -99,8 +99,21 @@ pipeline {
}
}
post {
success {
script {
// Solo lanzar cuando el build sea exitoso y en la rama main
if (env.BRANCH_NAME == 'main') {
build job: 'Aptly publish nightly repository',
wait: false,
parameters: [
string(name: 'TRIGGERED_BY', value: "${env.JOB_NAME}-${env.BUILD_NUMBER}")
]
}
}
}
always {
notifyBuildStatus('narenas@qindel.com')
notifyBuildStatus('opengnsys@qindel.com')
}
}
}

View File

@ -445,23 +445,50 @@ export class DeployImageComponent implements OnInit{
dialogRef.afterClosed().subscribe((result: { [x: string]: any; }) => {
if (result !== undefined) {
const payload = {
const basePayload: any = {
method: this.selectedMethod,
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.selectedMethod === 'p2p' ? this.p2pMode : null,
p2pTime: this.selectedMethod === 'p2p' && this.p2pMode === 'seeder' ? this.p2pTime : null,
mcastIp: this.selectedMethod === 'udpcast' ? this.mcastIp : null,
mcastPort: this.selectedMethod === 'udpcast' ? this.mcastPort : null,
mcastMode: this.selectedMethod === 'udpcast' ? this.mcastMode : null,
mcastSpeed: this.selectedMethod === 'udpcast' ? this.mcastSpeed : null,
maxTime: this.selectedMethod === 'udpcast' ? this.mcastMaxTime : null,
maxClients: this.selectedMethod === 'udpcast' ? this.mcastMaxClients : null,
imageName: this.selectedImage.name,
imageUuid: this.selectedImage.uuid,
type: this.imageType
};
if (this.selectedMethod === 'p2p' && this.p2pMode) {
basePayload['p2pMode'] = this.p2pMode;
}
if (this.selectedMethod === 'p2p' && this.p2pMode === 'seeder' && this.p2pTime) {
basePayload['p2pTime'] = this.p2pTime;
}
if (this.selectedMethod === 'udpcast' || this.selectedMethod === 'udpcast-direct' && this.mcastIp) {
basePayload['mcastIp'] = this.mcastIp;
}
if (this.selectedMethod === 'udpcast' || this.selectedMethod === 'udpcast-direct' && this.mcastPort) {
basePayload['mcastPort'] = this.mcastPort;
}
if (this.selectedMethod === 'udpcast' || this.selectedMethod === 'udpcast-direct' && this.mcastMode) {
basePayload['mcastMode'] = this.mcastMode;
}
if (this.selectedMethod === 'udpcast' || this.selectedMethod === 'udpcast-direct' && this.mcastSpeed) {
basePayload['mcastSpeed'] = this.mcastSpeed;
}
if (this.selectedMethod === 'udpcast' || this.selectedMethod === 'udpcast-direct' && this.mcastMaxTime) {
basePayload['maxTime'] = this.mcastMaxTime;
}
if (this.selectedMethod === 'udpcast' || this.selectedMethod === 'udpcast-direct' && this.mcastMaxClients) {
basePayload['maxClients'] = this.mcastMaxClients;
}
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: result['taskId'] ? result['taskId']['@id'] : result['@id'],
parameters: payload,
parameters: basePayload,
order: result['executionOrder'] || 1,
type: 'deploy-image',
}).subscribe({

View File

@ -246,14 +246,18 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes
if (partition.partitionNumber === 0) {
disk!.totalDiskSize = this.convertBytesToGB(partition.size);
} else {
const isFirstPartitionGPT = partition.partitionNumber === 1 && this.partitionCode === 'GPT';
const hasValidExistingData = partition.size > 0 && partition.partitionCode && partition.filesystem;
const shouldUseEFIDefaults = isFirstPartitionGPT && !hasValidExistingData;
disk!.partitions.push({
uuid: partition.uuid,
partitionNumber: partition.partitionNumber,
size: this.convertBytesToGB(partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 512 : partition.size),
size: this.convertBytesToGB(shouldUseEFIDefaults ? 512 : partition.size),
memoryUsage: partition.memoryUsage,
partitionCode: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 'EFI' : this.validatePartitionCode(partition.partitionCode),
filesystem: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 'FAT32' : partition.filesystem,
sizeBytes: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 512 : partition.size,
partitionCode: shouldUseEFIDefaults ? 'EFI' : this.validatePartitionCode(partition.partitionCode),
filesystem: shouldUseEFIDefaults ? 'FAT32' : partition.filesystem,
sizeBytes: shouldUseEFIDefaults ? 512 : partition.size,
format: false,
color: this.getColorForPartition(partition.partitionNumber),
percentage: 0,

View File

@ -533,4 +533,274 @@ mat-spinner {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.script-input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.script-input-header h4 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
color: #2c3e50;
font-weight: 600;
}
.script-stats {
display: flex;
gap: 16px;
align-items: center;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6c757d;
background: white;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.stat-item.valid {
color: #28a745;
border-color: #28a745;
background: #f8fff9;
}
.stat-item.invalid {
color: #dc3545;
border-color: #dc3545;
background: #fff8f8;
}
.stat-item mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
.script-textarea {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
background: #f8f9fa;
}
.detected-params {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.detected-params h4 {
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
color: #856404;
font-weight: 600;
}
.params-grid .mat-mdc-chip-listbox {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.param-chip {
background: #fff !important;
border: 1px solid #ffeaa7 !important;
color: #856404 !important;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.action-buttons button {
display: flex;
align-items: center;
gap: 8px;
}
.script-selector-container {
margin-bottom: 20px;
}
.script-option {
display: flex;
flex-direction: column;
gap: 2px;
}
.script-name {
font-weight: 500;
color: #2c3e50;
}
.script-description {
font-size: 12px;
color: #6c757d;
opacity: 0.8;
}
/* Tarjetas de vista previa */
.script-preview-container {
margin-top: 20px;
}
.script-card, .params-card {
margin-bottom: 16px;
border: 1px solid #e3f2fd !important;
border-radius: 12px !important;
}
.script-card .mat-mdc-card-header,
.params-card .mat-mdc-card-header {
background: #f8f9fa;
border-radius: 12px 12px 0 0;
}
.script-card .mat-mdc-card-title,
.params-card .mat-mdc-card-title {
display: flex;
align-items: center;
gap: 8px;
color: #2c3e50;
font-weight: 600;
}
.script-card .mat-mdc-card-subtitle,
.params-card .mat-mdc-card-subtitle {
color: #6c757d;
margin-top: 4px;
}
.script-content-wrapper {
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
max-height: 300px;
overflow-y: auto;
}
.script-preview {
background-color: #ffffff;
border: 1px solid #e9ecef;
padding: 16px;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
min-height: 60px;
max-height: 280px;
overflow-y: auto;
}
.script-params-section {
margin-top: 16px;
}
.params-count {
background: #667eea !important;
color: white !important;
font-size: 11px;
height: 20px;
min-height: 20px;
padding: 0 8px;
}
.params-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.param-field {
position: relative;
}
/* Estados vacíos */
.empty-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: #f8f9fa;
border: 2px dashed #e9ecef;
border-radius: 12px;
margin: 20px 0;
}
.empty-state-content {
text-align: center;
color: #6c757d;
}
.empty-state-content mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: #dee2e6;
margin-bottom: 16px;
}
.empty-state-content h3 {
margin: 0 0 8px 0;
color: #495057;
font-weight: 500;
}
.empty-state-content p {
margin: 0;
font-size: 14px;
max-width: 300px;
}
/* Mejoras en chips de acción */
::ng-deep .action-chip mat-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
margin-right: 4px !important;
}
/* Responsive design */
@media (max-width: 768px) {
.script-input-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.script-stats {
flex-wrap: wrap;
gap: 8px;
}
.params-form {
grid-template-columns: 1fr;
}
.action-buttons {
justify-content: center;
}
}

View File

@ -86,50 +86,168 @@
<div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="commandType" required class="action-chip-listbox">
<mat-chip-option value="new" class="action-chip create-chip firmware-chip" (click)="commandType = 'new'">
<mat-icon>add_circle</mat-icon>
<span>Nuevo Script</span>
</mat-chip-option>
<mat-chip-option value="existing" class="action-chip update-chip firmware-chip" (click)="commandType = 'existing'">
<mat-icon>folder</mat-icon>
<span>Script Guardado</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<!-- Nuevo Script -->
<div *ngIf="commandType === 'new'" class="new-command-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Ingrese el script</mat-label>
<textarea matInput [(ngModel)]="newScript" rows="6" placeholder="Escriba su script aquí"></textarea>
</mat-form-field>
<button mat-flat-button color="primary" (click)="saveNewScript()">Guardar Script</button>
</div>
<div *ngIf="commandType === 'existing'">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">{{ script.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="selectedScript && commandType === 'existing'" class="script-container">
<div class="script-content">
<h3>Script:</h3>
<div class="script-preview" [innerHTML]="scriptContent"></div>
<div class="script-input-header">
<h4>
<mat-icon>edit_note</mat-icon>
Editor de Script
</h4>
<div class="script-stats" *ngIf="newScript.trim()">
<span class="stat-item">
<mat-icon>format_list_numbered</mat-icon>
{{ getLineCount(newScript) }} líneas
</span>
<span class="stat-item">
<mat-icon>text_fields</mat-icon>
{{ newScript.length }} caracteres
</span>
<span class="stat-item" [ngClass]="{'valid': isScriptValid(newScript), 'invalid': !isScriptValid(newScript)}">
<mat-icon>{{ isScriptValid(newScript) ? 'check_circle' : 'error' }}</mat-icon>
{{ isScriptValid(newScript) ? 'Válido' : 'Requiere contenido' }}
</span>
</div>
</div>
<div class="script-params" *ngIf="parameterNames.length > 0 && selectedScript.parameters">
<h3>Ingrese los parámetros:</h3>
<div *ngFor="let paramName of parameterNames">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ paramName }}</mat-label>
<input matInput [ngModel]="parameters[paramName]" (ngModelChange)="onParamChange(paramName, $event)" placeholder="Valor para {{ paramName }}">
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Ingrese el script</mat-label>
<textarea
matInput
[(ngModel)]="newScript"
(ngModelChange)="onNewScriptChange()"
rows="8"
placeholder="#!/bin/bash&#10;# Escriba su script aquí&#10;echo 'Iniciando script...'&#10;&#10;# Sus comandos aquí"
class="script-textarea"></textarea>
<mat-hint>Escriba comandos de shell/bash. Use &#64;parametro para variables dinámicas.</mat-hint>
</mat-form-field>
<!-- Vista previa de parámetros detectados en nuevo script -->
<div *ngIf="newScriptParameters.length > 0" class="detected-params">
<h4>
<mat-icon>tune</mat-icon>
Parámetros detectados
</h4>
<div class="params-grid">
<mat-chip-listbox>
<mat-chip-option *ngFor="let param of newScriptParameters" class="param-chip" selected>
<mat-icon>label</mat-icon>
&#64;{{ param }}
</mat-chip-option>
</mat-chip-listbox>
</div>
</div>
<div class="action-buttons">
<button
mat-flat-button
color="primary"
[disabled]="!isScriptValid(newScript)"
(click)="saveNewScript()">
<mat-icon>save</mat-icon>
Guardar Script
</button>
</div>
</div>
<!-- Script Existente -->
<div *ngIf="commandType === 'existing'">
<div class="script-selector-container">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">
<div class="script-option">
<span class="script-name">{{ script.name }}</span>
<span class="script-description" *ngIf="script.description">{{ script.description }}</span>
</div>
</mat-option>
</mat-select>
<mat-hint *ngIf="scripts.length === 0">No hay scripts guardados disponibles</mat-hint>
<mat-hint *ngIf="scripts.length > 0">{{ scripts.length }} script(s) disponible(s)</mat-hint>
</mat-form-field>
</div>
<!-- Vista previa del script seleccionado -->
<div *ngIf="selectedScript && commandType === 'existing'" class="script-preview-container">
<mat-card class="script-card">
<mat-card-header>
<mat-card-title>
<mat-icon>preview</mat-icon>
Vista previa del script
</mat-card-title>
<mat-card-subtitle>{{ selectedScript.name }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="script-content-wrapper">
<div class="script-preview" [innerHTML]="scriptContent"></div>
</div>
</mat-card-content>
</mat-card>
<!-- Parámetros del script -->
<div *ngIf="parameterNames.length > 0 && selectedScript.parameters" class="script-params-section">
<mat-card class="params-card">
<mat-card-header>
<mat-card-title>
<mat-icon>settings</mat-icon>
Parámetros del script
<mat-chip class="params-count">{{ parameterNames.length }}</mat-chip>
</mat-card-title>
<mat-card-subtitle>Configure los valores para los parámetros requeridos</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="params-form">
<div *ngFor="let paramName of parameterNames; trackBy: trackByParam" class="param-field">
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ paramName }}</mat-label>
<input
matInput
[ngModel]="parameters[paramName]"
(ngModelChange)="onParamChange(paramName, $event)"
[placeholder]="'Valor para ' + paramName"
[required]="true">
<mat-icon matPrefix>label</mat-icon>
<mat-hint>Parámetro: &#64;{{ paramName }}</mat-hint>
<mat-error *ngIf="!parameters[paramName]">Este parámetro es requerido</mat-error>
</mat-form-field>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
</div>
<!-- Estado vacío para script existente -->
<div *ngIf="commandType === 'existing' && !selectedScript" class="empty-state">
<div class="empty-state-content">
<mat-icon>folder_open</mat-icon>
<h3>Selecciona un script</h3>
<p>Elige un script guardado de la lista para ver su contenido y configurar parámetros.</p>
</div>
</div>
<!-- Estado vacío para nuevo script -->
<div *ngIf="commandType === 'new' && !newScript.trim()" class="empty-state">
<div class="empty-state-content">
<mat-icon>edit_note</mat-icon>
<h3>Escribe tu script</h3>
<p>Crea un nuevo script escribiendo comandos de shell/bash en el editor de texto.</p>
</div>
</div>
</div>
</div>

View File

@ -35,6 +35,7 @@ export class RunScriptAssistantComponent implements OnInit{
selection = new SelectionModel(true, []);
parameterNames: string[] = Object.keys(this.parameters);
runScriptContext: any = null;
newScriptParameters: string[] = [];
constructor(
private http: HttpClient,
@ -50,15 +51,12 @@ export class RunScriptAssistantComponent implements OnInit{
this.clientData = JSON.parse(params['clientData']);
}
if (params['runScriptContext']) {
this.runScriptContext = params['runScriptContext'];
this.runScriptContext = JSON.parse(params['runScriptContext']);
}
});
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
this.selectedClients = this.clientData.filter(
(client: { selected: boolean; status: string }) => client.selected
);
this.initializeClientSelection();
this.allSelected = this.clientData.length > 0 && this.clientData.every((client: { selected: boolean }) => client.selected);
@ -66,9 +64,6 @@ export class RunScriptAssistantComponent implements OnInit{
}
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
}
get runScriptTitle(): string {
@ -174,6 +169,24 @@ export class RunScriptAssistantComponent implements OnInit{
this.scriptContent = updatedScript;
}
getLineCount(text: string): number {
if (!text) return 0;
return text.split('\n').length;
}
isScriptValid(script: string): boolean {
return Boolean(script && script.trim().length > 0);
}
onNewScriptChange(): void {
const matches = this.newScript.match(/@(\w+)/g) || [];
this.newScriptParameters = Array.from(new Set(matches.map(m => m.slice(1))));
}
trackByParam(index: number, paramName: string): string {
return paramName;
}
save(): void {
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
@ -205,13 +218,21 @@ export class RunScriptAssistantComponent implements OnInit{
}
openScheduleModal(): void {
let scope = this.runScriptContext.type;
let scope = this.runScriptContext?.type || 'clients';
let selectedClients = null;
if ((!this.runScriptContext || this.runScriptContext.type === 'client' || this.selectedClients.length === 1) && this.selectedClients && this.selectedClients.length > 0) {
if (this.selectedClients.length === 0 && this.clientData.length > 0) {
this.updateSelectedClients();
}
const isOrganizationalContext = this.runScriptContext?.type &&
['organizational-unit', 'classroom', 'classrooms-group', 'clients-group'].includes(this.runScriptContext.type);
if (!isOrganizationalContext && this.selectedClients && this.selectedClients.length > 0) {
scope = 'clients';
selectedClients = this.selectedClients;
} else if (isOrganizationalContext && this.selectedClients && this.selectedClients.length > 0) {
selectedClients = null;
}
const dialogRef = this.dialog.open(CreateTaskComponent, {
@ -229,9 +250,9 @@ export class RunScriptAssistantComponent implements OnInit{
console.log(result);
if (result) {
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: result.taskId['@id'],
commandTask: result['@id'],
content: this.commandType === 'existing' ? this.scriptContent : this.newScript,
order: result.executionOrder,
order: result['executionOrder'] || 1,
type: 'run-script',
}).subscribe({
next: () => {
@ -244,4 +265,35 @@ export class RunScriptAssistantComponent implements OnInit{
}
});
}
private initializeClientSelection(): void {
const context = this.runScriptContext;
this.clientData.forEach((client: { selected: boolean; status: string}) => {
client.selected = true;
});
if (context && typeof context === 'object' && context.type &&
['classroom', 'classrooms-group', 'clients-group'].includes(context.type)) {
this.clientData.forEach((client: { selected: boolean; status: string}) => {
client.selected = false;
});
}
else if (context && typeof context === 'object' && context.type === 'client') {
this.clientData.forEach((client: { selected: boolean; status: string; name: string; uuid: string}) => {
client.selected = client.name === context.name || client.uuid === context.uuid || client.uuid === context['@id'];
});
}
else if (context && Array.isArray(context)) {
this.clientData.forEach((client: { selected: boolean; status: string; name: string; uuid: string}) => {
client.selected = context.some(ctx =>
ctx.name === client.name || ctx.uuid === client.uuid || ctx['@id'] === client.uuid
);
});
}
this.updateSelectedClients();
}
}