develop #33

Merged
maranda merged 12 commits from develop into main 2025-08-05 10:46:45 +02:00
40 changed files with 3239 additions and 309 deletions

View File

@ -1,4 +1,22 @@
# Changelog
## [0.18.0] - 2025-08-04
### Added
- Se ha añadido la posibilidad de visualizar logs en tiempo real de Grafana. Tanto en los componentes como en los clientes.
- Se ha añadido la funcionaldad e integracion con OgGit.
- En el particionador, se ha añadido una integracion para comprobar los tamaños de las particiones.
### Improved
- Sistema de cola de acciones.
---
## [0.17.0] - 2025-07-15
### Added
- Se ha añadido la funcionalidad para tagear commits en el apartado de imágenes git.
### Improved
- Se ha corregido el particionador, para cuando un equipo es EFI, ahora aparece la primera particion completada.
## [0.16.0] - 2025-06-27
### Added
- Sistema de logs en tiempo real.

View File

@ -159,6 +159,9 @@ import { ClientPendingTasksComponent } from './components/task-logs/client-pendi
import { QueueConfirmationModalComponent } from './shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { ModalOverlayComponent } from './shared/modal-overlay/modal-overlay.component';
import { ScrollToTopComponent } from './shared/scroll-to-top/scroll-to-top.component';
import { CreateTagModalComponent } from './components/repositories/show-git-images/create-tag-modal/create-tag-modal.component';
import { CreateBranchModalComponent } from './components/repositories/show-git-images/create-branch-modal/create-branch-modal.component';
import { ClientLogsModalComponent } from './components/groups/shared/client-logs-modal/client-logs-modal.component';
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './locale/', '.json');
@ -274,7 +277,10 @@ registerLocaleData(localeEs, 'es-ES');
SoftwareProfilePartitionComponent,
ClientPendingTasksComponent,
ModalOverlayComponent,
ScrollToTopComponent
ScrollToTopComponent,
CreateTagModalComponent,
CreateBranchModalComponent,
ClientLogsModalComponent
],
bootstrap: [AppComponent],
imports: [BrowserModule,

View File

@ -33,9 +33,9 @@ export class CommandsTaskComponent implements OnInit {
columns = [
{ columnDef: 'id', header: 'ID', cell: (task: any) => task.id },
{ columnDef: 'name', header: 'Nombre de tarea', cell: (task: any) => task.name },
{ columnDef: 'organizationalUnit', header: 'Ámbito', cell: (task: any) => task.organizationalUnit.name },
{ columnDef: 'organizationalUnit', header: 'Ámbito', cell: (task: any) => task.scope },
{ columnDef: 'management', header: 'Gestiones', cell: (task: any) => task.schedules },
{ columnDef: 'nextExecution', header: 'Próxima ejecución', cell: (task: any) => this.datePipe.transform(task.nextExecution, 'dd/MM/yyyy HH:mm:ss', 'UTC') },
{ columnDef: 'nextExecution', header: 'Próxima ejecución', cell: (task: any) => this.datePipe.transform(task.nextExecution, 'dd/MM/yyyy HH:mm:ss') },
{ columnDef: 'createdBy', header: 'Creado por', cell: (task: any) => task.createdBy },
];

View File

@ -22,7 +22,6 @@
<input matInput formControlName="executionTime" placeholder="08:00" type="time">
</mat-form-field>
<!-- Mostrar solo si no es 'none' -->
<div *ngIf="form.get('recurrenceType')?.value !== 'none'" class="mb-4">
<label>Días de la semana:</label>
<div class="weekday-toggle-group">
@ -37,7 +36,6 @@
</div>
</div>
<!-- Selección de meses -->
<div *ngIf="form.get('recurrenceType')?.value !== 'none'" >
<label>Meses:</label>
<div class="month-toggle-row" *ngFor="let row of monthRows">
@ -52,7 +50,6 @@
</div>
</div>
<!-- Rango de fechas -->
<div *ngIf="form.get('recurrenceType')?.value !== 'none'" class="custom-time" formGroupName="recurrenceDetails">
<mat-form-field appearance="fill" class="w-half">
<mat-label>Desde</mat-label>

View File

@ -19,6 +19,14 @@ mat-form-field {
margin-bottom: 16px;
}
mat-form-field.mat-form-field-disabled {
opacity: 0.7;
}
mat-form-field.mat-form-field-disabled .mat-form-field-label {
color: #666;
}
.loading-spinner {
display: block;
margin: 0 auto;
@ -63,3 +71,148 @@ mat-form-field {
gap: 1em;
padding: 1.5em;
}
/* Estilos para la selección de clientes */
.clients-selection {
margin-bottom: 16px;
}
.clients-selection h4 {
margin-bottom: 16px;
color: #333;
font-weight: 500;
}
.pre-selected-info {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background-color: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 4px;
margin-bottom: 16px;
color: #1976d2;
font-size: 14px;
}
.pre-selected-info mat-icon {
color: #2196f3;
font-size: 20px;
}
.loading-clients {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
color: #666;
}
.clients-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.selected-count {
font-weight: 500;
color: #1976d2;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
max-height: 300px;
overflow-y: auto;
padding: 8px 0;
}
.client-card {
transition: all 0.2s ease;
border: 2px solid transparent;
position: relative;
}
.client-card.pre-selected {
border-color: #4caf50;
background-color: #e8f5e8;
}
.client-card.pre-selected:hover {
border-color: #45a049;
background-color: #d4edda;
}
.client-card mat-card-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
}
.client-info {
flex: 1;
}
.client-name {
font-weight: 500;
margin-bottom: 4px;
color: #333;
}
.client-details {
display: flex;
gap: 8px;
font-size: 12px;
color: #666;
}
.client-ip {
font-family: monospace;
}
.client-status {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
}
.status-og-live {
background-color: #4caf50;
color: white;
}
.status-offline {
background-color: #f44336;
color: white;
}
.status-unknown {
background-color: #ff9800;
color: white;
}
.selected-icon {
color: #1976d2;
font-size: 20px;
}
/* Responsive design */
@media (max-width: 768px) {
.clients-grid {
grid-template-columns: 1fr;
}
.clients-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}

View File

@ -5,13 +5,11 @@
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<!-- Toggle entre crear o añadir -->
<mat-radio-group *ngIf="data?.source === 'assistant'" [(ngModel)]="taskMode" class="task-mode-selection" name="taskMode">
<mat-radio-button value="create">Crear tarea</mat-radio-button>
<mat-radio-button value="add">Introducir en tarea existente</mat-radio-button>
</mat-radio-group>
<!-- Selección de tarea existente -->
<div *ngIf="taskMode === 'add'" class="select-task">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione una tarea</mat-label>
@ -33,7 +31,6 @@
</mat-form-field>
</div>
<!-- Formulario de nueva tarea -->
<form *ngIf="taskMode === 'create' && taskForm && !loading" [formGroup]="taskForm" class="task-form">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'nameLabel' | translate }}</mat-label>
@ -48,15 +45,17 @@
<mat-form-field appearance="fill" class="full-width">
<mat-label>Ámbito</mat-label>
<mat-select formControlName="scope" (selectionChange)="onScopeChange($event.value)">
<mat-select formControlName="scope" (selectionChange)="onScopeChange($event.value)"
[disabled]="data?.clients && data.clients.length >= 1">
<mat-option value="organizational-unit">Unidad Organizativa</mat-option>
<mat-option value="classrooms-group">Grupo de aulas</mat-option>
<mat-option value="classroom">Aulas</mat-option>
<mat-option value="clients-group">Grupos de clientes</mat-option>
<mat-option value="clients">Clientes</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-form-field *ngIf="taskForm.get('scope')?.value !== 'clients'" appearance="fill" class="full-width">
<mat-label>{{ 'organizationalUnitLabel' | translate }}</mat-label>
<mat-select formControlName="organizationalUnit">
<mat-option *ngFor="let unit of availableOrganizationalUnits" [value]="unit['@id']">
@ -66,6 +65,40 @@
</mat-select>
</mat-form-field>
<div *ngIf="taskForm.get('scope')?.value === 'clients'" class="clients-selection">
<h4>Clientes seleccionados</h4>
<div *ngIf="data?.selectedClients && data.selectedClients.length > 0" class="pre-selected-info">
<mat-icon>info</mat-icon>
<span>Los clientes han sido pre-seleccionados desde el componente de despliegue de imágenes.</span>
</div>
<div class="clients-list">
<div class="clients-grid">
<mat-card
*ngFor="let client of clients"
class="client-card"
[class.pre-selected]="isClientPreSelected(client)"
>
<mat-card-content>
<div class="client-info">
<div class="client-name">{{ client.name || client.hostname }}</div>
<div class="client-details">
<span class="client-ip">{{ client.ip }}</span>
<span class="client-status" [class]="'status-' + client.status">
{{ client.status }}
</span>
</div>
</div>
<mat-icon class="selected-icon">
check_circle
</mat-icon>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
<mat-checkbox *ngIf="!editing" formControlName="scheduleAfterCreate">
¿Quieres programar la tarea al finalizar su creación?
</mat-checkbox>

View File

@ -29,6 +29,7 @@ export class CreateTaskComponent implements OnInit {
existingTasks: any[] = [];
selectedExistingTask: string | null = null;
executionOrder: number | null = null;
selectedClients: any[] = [];
constructor(
private fb: FormBuilder,
@ -41,13 +42,32 @@ export class CreateTaskComponent implements OnInit {
) {
this.baseUrl = this.configService.apiUrl;
this.apiUrl = `${this.baseUrl}/command-tasks`;
let initialScope = '';
if (this.data?.selectedClients && this.data.selectedClients.length > 0) {
initialScope = 'clients';
} else if (this.data?.scope) {
initialScope = this.data.scope;
} else if (this.data?.runScriptContext) {
initialScope = this.data.runScriptContext.type || '';
}
this.taskForm = this.fb.group({
scope: [ this.data?.scope ? this.data.scope : '', Validators.required],
scope: [initialScope, Validators.required],
name: ['', Validators.required],
organizationalUnit: [ this.data?.organizationalUnit ? this.data.organizationalUnit : null, Validators.required],
organizationalUnit: [ this.data?.organizationalUnit ? this.data.organizationalUnit : null],
notes: [''],
scheduleAfterCreate: [false]
});
if (this.data?.selectedClients && Array.isArray(this.data.selectedClients)) {
this.selectedClients = [...this.data.selectedClients];
this.clients = [...this.data.selectedClients];
}
setTimeout(() => {
this.onScopeChange(initialScope);
}, 0);
}
ngOnInit(): void {
@ -111,10 +131,31 @@ export class CreateTaskComponent implements OnInit {
}
onScopeChange(scope: string): void {
this.filterUnits(scope).subscribe(filteredUnits => {
this.availableOrganizationalUnits = filteredUnits;
if (scope === 'clients') {
if (this.data?.selectedClients && this.data.selectedClients.length > 0) {
this.selectedClients = [...this.data.selectedClients];
this.clients = [...this.data.selectedClients];
} else {
this.toastr.error('No hay clientes pre-seleccionados para este ámbito');
this.taskForm.get('scope')?.setValue('');
return;
}
this.taskForm.get('organizationalUnit')?.setValue('');
});
this.taskForm.get('organizationalUnit')?.clearValidators();
} else {
this.filterUnits(scope).subscribe(filteredUnits => {
this.availableOrganizationalUnits = filteredUnits;
if (!this.data?.organizationalUnit) {
this.taskForm.get('organizationalUnit')?.setValue('');
}
this.taskForm.get('organizationalUnit')?.setValidators(Validators.required);
});
}
this.taskForm.get('organizationalUnit')?.updateValueAndValidity();
}
isClientPreSelected(client: any): boolean {
return this.data?.selectedClients && this.data.selectedClients.some((c: any) => c.uuid === client.uuid);
}
startUnitsFilter(): Promise<void> {
@ -209,14 +250,31 @@ export class CreateTaskComponent implements OnInit {
}
const formData = this.taskForm.value;
const scope = formData.scope;
if (scope === 'clients' && this.selectedClients.length === 0) {
this.toastr.error('Debe seleccionar al menos un cliente');
return;
}
if (scope !== 'clients' && !formData.organizationalUnit) {
this.toastr.error('Debe seleccionar una unidad organizativa');
return;
}
const payload: any = {
name: formData.name,
scope: formData.scope,
organizationalUnit: formData.organizationalUnit,
notes: formData.notes || '',
};
if (scope === 'clients') {
payload.clients = this.selectedClients.map(client => client.uuid);
payload.organizationalUnit = null;
} else {
payload.organizationalUnit = formData.organizationalUnit;
}
if (this.editing) {
const taskId = this.data.task.uuid;
this.http.patch<any>(`${this.apiUrl}/${taskId}`, payload).subscribe({

View File

@ -28,7 +28,7 @@ export class ShowTaskScheduleComponent implements OnInit{
columns = [
{ columnDef: 'id', header: 'ID', cell: (schedule: any) => schedule.id },
{ columnDef: 'recurrenceType', header: 'Recurrencia', cell: (schedule: any) => schedule.recurrenceType },
{ columnDef: 'time', header: 'Hora de ejecución', cell: (schedule: any) => this.datePipe.transform(schedule.executionTime, 'HH:mm', 'UTC') },
{ columnDef: 'time', header: 'Hora de ejecución', cell: (schedule: any) => this.datePipe.transform(schedule.executionTime, 'HH:mm') },
{ columnDef: 'daysOfWeek', header: 'Dias de la semana', cell: (schedule: any) => schedule.recurrenceDetails.daysOfWeek },
{ columnDef: 'months', header: 'Meses', cell: (schedule: any) => schedule.recurrenceDetails.months },
{ columnDef: 'enabled', header: 'Activo', cell: (schedule: any) => schedule.enabled }

View File

@ -107,18 +107,18 @@ export class ExecuteCommandComponent implements OnInit {
this.arrayCommands = this.arrayCommands.map(command => {
if (allOffOrDisconnected) {
command.disabled = command.slug !== 'power-on';
command.disabled = command.slug !== 'power-on' && command.slug !== 'create-image';
} else if (allSameState) {
if (states[0] === 'off' || states[0] === 'disconnected') {
command.disabled = command.slug !== 'power-on';
command.disabled = !['power-on', 'create-image'].includes(command.slug);
} else {
command.disabled = !['power-off', 'reboot', 'login', 'create-image', 'deploy-image', 'remove-cache-image', 'partition', 'run-script', 'software-inventory'].includes(command.slug);
}
} else {
if (command.slug === 'create-image'|| command.slug === 'software-inventory') {
if (command.slug === 'software-inventory') {
command.disabled = multipleClients;
} else if (
['power-on', 'power-off', 'reboot', 'login', 'deploy-image', 'partition', 'remove-cache-image', 'run-script'].includes(command.slug)
['power-on', 'power-off', 'reboot', 'login', 'deploy-image', 'partition', 'remove-cache-image', 'run-script', 'create-image'].includes(command.slug)
) {
command.disabled = false;
} else {

View File

@ -184,6 +184,21 @@ mat-dialog-content {
opacity: 1;
}
.overview-card.clickable {
cursor: pointer;
transition: all 0.3s ease;
}
.overview-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.overview-card.clickable:active {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.overview-icon {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
@ -215,6 +230,28 @@ mat-dialog-content {
color: #6c757d;
}
.click-hint {
margin-top: 0.5rem !important;
font-size: 0.75rem !important;
color: #667eea !important;
font-weight: 500;
opacity: 0.8;
transition: opacity 0.3s ease;
display: flex;
align-items: center;
gap: 0.25rem;
}
.hint-icon {
font-size: 0.875rem !important;
width: 0.875rem !important;
height: 0.875rem !important;
}
.overview-card.clickable:hover .click-hint {
opacity: 1;
}
/* ===== BADGES DE ESTADO ===== */
.status-badge {
display: inline-block;
@ -554,4 +591,296 @@ mat-dialog-content {
margin: 0;
color: #666;
font-weight: 500;
}
/* ===== SECCIÓN DE LOGS ===== */
.logs-section {
margin-top: 2rem;
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e9ecef;
}
.logs-header {
margin-bottom: 1rem;
}
.logs-header h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logs-header h3::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 2px;
}
.logs-header p {
margin: 0;
font-size: 0.9rem;
color: #6c757d;
}
.logs-container {
display: flex;
justify-content: center;
align-items: center;
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
border: 1px solid #e9ecef;
}
.ogboot-logs-iframe {
border: none;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: white;
}
.dhcp-logs-iframe {
border: none;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: white;
}
/* Responsive para logs */
@media (max-width: 768px) {
.logs-section {
margin-top: 1.5rem;
padding: 1rem;
}
.logs-container {
padding: 0.5rem;
}
.ogboot-logs-iframe {
width: 100% !important;
height: 150px !important;
}
}
/* ===== TARJETAS ACTIVAS ===== */
.overview-card.clickable.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.overview-card.clickable.active .overview-icon {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.overview-card.clickable.active .status-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.overview-card.clickable.active .click-hint {
color: rgba(255, 255, 255, 0.8);
}
.overview-card.clickable.active h3 {
color: white;
}
.overview-card.clickable.active p {
color: rgba(255, 255, 255, 0.9);
}
/* ===== CONTENIDO DINÁMICO ===== */
.dynamic-content {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
animation: fadeInUp 0.6s ease-out;
}
.section-content {
padding: 2rem;
}
/* ===== HEADER DE SECCIÓN Y BOTÓN DE LOGS ===== */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.section-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-header h2::before {
content: '';
width: 4px;
height: 24px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 2px;
}
.logs-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 25px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.logs-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.logs-button:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.logs-button mat-icon {
font-size: 1.1rem;
width: 1.1rem;
height: 1.1rem;
}
/* Responsive para el header de sección */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.section-header h2 {
font-size: 1.25rem;
}
.logs-button {
padding: 0.5rem 1.25rem;
font-size: 0.8rem;
}
.logs-button mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
/* ===== HEADER DE SECCIÓN Y BOTÓN DE LOGS ===== */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.section-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-header h2::before {
content: '';
width: 4px;
height: 24px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 2px;
}
.logs-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 25px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.logs-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.logs-button:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.logs-button mat-icon {
font-size: 1.1rem;
width: 1.1rem;
height: 1.1rem;
}
/* Responsive para el header de sección */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.section-header h2 {
font-size: 1.25rem;
}
.logs-button {
padding: 0.5rem 1.25rem;
font-size: 0.8rem;
}
.logs-button mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}

View File

@ -1,4 +1,3 @@
<!-- Header con bienvenida -->
<div class="welcome-header">
<div class="welcome-content">
<div class="welcome-icon">
@ -17,9 +16,7 @@
</div>
</div>
<!-- Contenido principal -->
<mat-dialog-content [ngClass]="{'loading': loading}">
<!-- Spinner de carga -->
<div class="spinner-container" *ngIf="loading">
<div class="loading-content">
<mat-spinner class="loading-spinner"></mat-spinner>
@ -27,11 +24,11 @@
</div>
</div>
<!-- Contenido principal cuando no está cargando -->
<div *ngIf="!loading" class="main-content">
<!-- Resumen rápido del sistema -->
<div class="system-overview">
<div class="overview-card">
<div class="overview-card clickable"
[ngClass]="{'active': selectedSection === 'repositories'}"
(click)="selectSection('repositories')">
<div class="overview-icon">
<mat-icon>cloud</mat-icon>
</div>
@ -41,7 +38,9 @@
</div>
</div>
<div class="overview-card">
<div class="overview-card clickable"
[ngClass]="{'active': selectedSection === 'ogboot'}"
(click)="selectSection('ogboot')">
<div class="overview-icon">
<mat-icon>storage</mat-icon>
</div>
@ -52,7 +51,9 @@
</div>
</div>
<div class="overview-card">
<div class="overview-card clickable"
[ngClass]="{'active': selectedSection === 'dhcp'}"
(click)="selectSection('dhcp')">
<div class="overview-icon">
<mat-icon>router</mat-icon>
</div>
@ -62,11 +63,22 @@
<p *ngIf="errorDhcp">Estado: <span class="status-badge offline">Error</span></p>
</div>
</div>
<div class="overview-card clickable"
[ngClass]="{'active': selectedSection === 'ogcore'}"
(click)="selectSection('ogcore')">
<div class="overview-icon">
<mat-icon>dns</mat-icon>
</div>
<div class="overview-content">
<h3>OgCore Server</h3>
<p>Logs del servidor</p>
</div>
</div>
</div>
<!-- Tabs principales -->
<mat-tab-group (selectedTabChange)="onTabChange($event)" class="main-tabs">
<mat-tab label="{{ 'repositoryLabel' | translate }}">
<div class="dynamic-content">
<div *ngIf="selectedSection === 'repositories'" class="section-content">
<div class="repositories-container">
<div *ngIf="repositories.length === 0" class="no-repositories">
<mat-icon class="no-data-icon">cloud_off</mat-icon>
@ -75,7 +87,6 @@
</div>
<div *ngIf="repositories.length > 0" class="repositories-selector-container">
<!-- Selector de repositorio -->
<div class="repository-selector">
<mat-form-field appearance="outline" class="repository-select-field">
<mat-label>Seleccionar repositorio</mat-label>
@ -88,11 +99,14 @@
</mat-form-field>
</div>
<!-- Información del repositorio seleccionado -->
<div *ngIf="selectedRepositoryUuid && !errorRepositories[selectedRepositoryUuid] && repositoryStatuses[selectedRepositoryUuid]" class="selected-repository-content">
<div class="repository-item">
<div class="repository-header">
<h3>{{ getSelectedRepositoryName() }}</h3>
<div class="section-header">
<h2>{{ getSelectedRepositoryName() }}</h2>
<button class="logs-button" (click)="scrollToLogs()">
<mat-icon>article</mat-icon>
<span>Ver logs</span>
</button>
</div>
<div class="repository-content">
@ -111,16 +125,30 @@
</app-status-tab>
</div>
</div>
<div class="repository-logs-section" id="repository-logs-section">
<div class="logs-header">
<h3>Datos del repositorio: {{ getSelectedRepositoryName() }}</h3>
<p>Información específica del repositorio seleccionado</p>
</div>
<div class="logs-container">
<iframe
src="https://localhost:3030/d-solo/ogrepo-logs/ogrepo-logs?orgId=1&timezone=browser&refresh=5s&var-query0=&editIndex=0&var-hostname=ogrepository2&theme=dark&panelId=1&__feature.dashboardSceneSolo"
width="100%"
height="600"
frameborder="0"
class="repository-logs-iframe">
</iframe>
</div>
</div>
</div>
<!-- Mensaje cuando no hay repositorio seleccionado -->
<div *ngIf="!selectedRepositoryUuid" class="no-repository-selected">
<mat-icon class="no-data-icon">storage</mat-icon>
<h3>Selecciona un repositorio</h3>
<p>Elige un repositorio de la lista para ver su estado detallado.</p>
</div>
<!-- Error al cargar repositorio -->
<div *ngIf="selectedRepositoryUuid && errorRepositories[selectedRepositoryUuid]" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
@ -134,15 +162,39 @@
</div>
</div>
</div>
</mat-tab>
</div>
<mat-tab label="OgBoot Server">
<div *ngIf="!errorOgBoot && !loadingOgBoot" class="tab-content">
<app-status-tab [loading]="loadingOgBoot" [diskUsage]="ogBootDiskUsage" [servicesStatus]="ogBootServicesStatus"
[installedOgLives]="installedOgLives" [diskUsageChartData]="ogBootDiskUsageChartData" [view]="view"
[colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="isDhcp"
[isRepository]="false">
</app-status-tab>
<div *ngIf="selectedSection === 'ogboot'" class="section-content">
<div *ngIf="!errorOgBoot && !loadingOgBoot" class="tab-content">
<div class="section-header">
<h2>OgBoot Server</h2>
<button class="logs-button" (click)="scrollToLogs()">
<mat-icon>article</mat-icon>
<span>Ver logs</span>
</button>
</div>
<app-status-tab [loading]="loadingOgBoot" [diskUsage]="ogBootDiskUsage" [servicesStatus]="ogBootServicesStatus"
[installedOgLives]="installedOgLives" [diskUsageChartData]="ogBootDiskUsageChartData" [view]="view"
[colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="isDhcp"
[isRepository]="false">
</app-status-tab>
<div class="logs-section" id="ogboot-logs-section">
<div class="logs-header">
<h3>Logs de OgBoot</h3>
<p>Logs en tiempo real del servidor OgBoot</p>
</div>
<div class="logs-container">
<iframe
src="https://localhost:3030/d-solo/ogboot-logs/ogboot-logs?orgId=1&timezone=browser&refresh=5s&theme=dark&panelId=1&__feature.dashboardSceneSolo"
width="100%"
height="800"
frameborder="0"
class="ogboot-logs-iframe">
</iframe>
</div>
</div>
</div>
<div *ngIf="loadingOgBoot" class="loading-container">
<div class="loading-content">
@ -161,14 +213,38 @@
</button>
</div>
</div>
</mat-tab>
</div>
<mat-tab label="DHCP Server">
<div *ngIf="!errorDhcp && !loadingDhcp" class="tab-content">
<app-status-tab [loading]="loadingDhcp" [diskUsage]="dhcpDiskUsage" [servicesStatus]="dhcpServicesStatus"
[subnets]="subnets" [diskUsageChartData]="dhcpDiskUsageChartData" [view]="view" [colorScheme]="colorScheme"
[isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="true" [isRepository]="false">
</app-status-tab>
<div *ngIf="selectedSection === 'dhcp'" class="section-content">
<div *ngIf="!errorDhcp && !loadingDhcp" class="tab-content">
<div class="section-header">
<h2>DHCP Server</h2>
<button class="logs-button" (click)="scrollToLogs()">
<mat-icon>article</mat-icon>
<span>Ver logs</span>
</button>
</div>
<app-status-tab [loading]="loadingDhcp" [diskUsage]="dhcpDiskUsage" [servicesStatus]="dhcpServicesStatus"
[subnets]="subnets" [diskUsageChartData]="dhcpDiskUsageChartData" [view]="view" [colorScheme]="colorScheme"
[isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="true" [isRepository]="false">
</app-status-tab>
<div class="logs-section" id="dhcp-logs-section">
<div class="logs-header">
<h3>Logs de DHCP</h3>
<p>Logs en tiempo real del servidor DHCP</p>
</div>
<div class="logs-container">
<iframe
src="https://localhost:3030/d-solo/ogdhcp-logs/ogdhcp-logs?orgId=1&timezone=browser&refresh=5s&theme=dark&panelId=1&__feature.dashboardSceneSolo"
width="100%"
height="800"
frameborder="0"
class="dhcp-logs-iframe">
</iframe>
</div>
</div>
</div>
<div *ngIf="loadingDhcp" class="loading-container">
<div class="loading-content">
@ -187,12 +263,36 @@
</button>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
<div *ngIf="selectedSection === 'ogcore'" class="section-content">
<div class="tab-content">
<div class="section-header">
<h2>OgCore Server</h2>
</div>
<div class="logs-section" id="ogcore-logs-section">
<div class="logs-header">
<h3>Logs de OgCore</h3>
<p>Logs en tiempo real del servidor OgCore</p>
</div>
<div class="logs-container">
<iframe
src="https://localhost:3030/d-solo/ogcore-logs/ogcore-logs?orgId=1&tab=transformations&theme=dark&panelId=1&__feature.dashboardSceneSolo&now-5m&to=now&timezone=browser"
width="100%"
height="800"
frameborder="0"
class="ogcore-logs-iframe">
</iframe>
</div>
</div>
</div>
</div>
</div>
</div>
</mat-dialog-content>
<!-- Footer con acciones -->
<mat-dialog-actions class="action-container">
<div class="action-info">
<p class="last-update">Última actualización: {{ lastUpdateTime }}</p>

View File

@ -1,8 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { ConfigService } from '@services/config.service';
import { MatTabChangeEvent } from '@angular/material/tabs';
import {ToastrService} from "ngx-toastr";
import { ToastrService } from "ngx-toastr";
@Component({
selector: 'app-global-status',
@ -31,6 +30,7 @@ export class GlobalStatusComponent implements OnInit {
repositoryStatuses: { [key: string]: any } = {};
lastUpdateTime: string = '';
selectedRepositoryUuid: string = '';
selectedSection: 'repositories' | 'ogboot' | 'dhcp' | 'ogcore' = 'repositories';
ogBootApiUrl: string;
ogBootDiskUsage: any = {};
@ -44,7 +44,8 @@ export class GlobalStatusComponent implements OnInit {
isDhcp: boolean = false;
isRepository: boolean = false;
// Loading específicos para cada sección
ogCoreApiUrl: string;
loadingOgBootOgLives: boolean = false;
loadingOgBootServices: boolean = false;
loadingOgBootDisk: boolean = false;
@ -60,6 +61,7 @@ export class GlobalStatusComponent implements OnInit {
this.baseUrl = this.configService.apiUrl;
this.ogBootApiUrl = `${this.baseUrl}/og-boot/status`;
this.dhcpApiUrl = `${this.baseUrl}/og-dhcp/status`;
this.ogCoreApiUrl = `${this.baseUrl}`;
this.repositoriesUrl = `${this.baseUrl}/image-repositories`;
this.ogBootDiskUsageChartData = [];
@ -290,23 +292,9 @@ export class GlobalStatusComponent implements OnInit {
this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorDhcp', false);
}
onTabChange(event: MatTabChangeEvent): void {
switch (event.index) {
case 0:
if (this.repositories.length === 0) {
this.loadRepositories(false);
}
break;
case 1:
this.loadOgBootStatus();
break;
case 2:
this.loadDhcpStatus();
break;
default:
break;
}
}
onRepositoryChange(repositoryUuid: string): void {
this.selectedRepositoryUuid = repositoryUuid;
@ -324,6 +312,15 @@ export class GlobalStatusComponent implements OnInit {
return selectedRepo ? selectedRepo.name : '';
}
getRepositoryIframeUrl(): string {
const repositoryName = this.getSelectedRepositoryName();
if (repositoryName) {
const encodedName = encodeURIComponent(repositoryName);
return `https://localhost:3030/d-solo/ogrepo-logs/ogrepo-logs?orgId=1&timezone=browser&refresh=5s&var-query0=&editIndex=0&var-hostname=${encodedName}&theme=dark&panelId=1&__feature.dashboardSceneSolo`;
}
return '';
}
refreshAll(): void {
this.loading = true;
this.updateLastUpdateTime();
@ -364,4 +361,71 @@ export class GlobalStatusComponent implements OnInit {
}
});
}
selectSection(section: 'repositories' | 'ogboot' | 'dhcp' | 'ogcore'): void {
this.selectedSection = section;
switch (section) {
case 'repositories':
break;
case 'ogboot':
if (!this.ogBootDiskUsage) {
this.loadOgBootStatus();
}
break;
case 'dhcp':
if (!this.dhcpDiskUsage) {
this.loadDhcpStatus();
}
break;
case 'ogcore':
// No se necesita cargar nada, solo mostrar los logs
break;
}
}
scrollToLogs(): void {
let logsSectionId: string;
switch (this.selectedSection) {
case 'repositories':
logsSectionId = 'repository-logs-section';
break;
case 'ogboot':
logsSectionId = 'ogboot-logs-section';
break;
case 'dhcp':
logsSectionId = 'dhcp-logs-section';
break;
case 'ogcore':
logsSectionId = 'ogcore-logs-section';
break;
default:
return;
}
const checkAndScroll = () => {
const logsSection = document.getElementById(logsSectionId);
if (logsSection) {
logsSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
return true;
}
return false;
};
const interval = setInterval(() => {
if (checkAndScroll()) {
clearInterval(interval);
}
}, 100);
setTimeout(() => {
clearInterval(interval);
}, 3000);
}
}

View File

@ -1,9 +1,7 @@
<app-loading [isLoading]="loading"></app-loading>
<div *ngIf="!loading" class="dashboard">
<!-- Sección de uso de recursos -->
<div class="resources-section">
<!-- Disk Usage Section -->
<div class="resource-card disk-usage-container">
<div class="resource-header">
<div class="resource-icon">
@ -32,13 +30,12 @@
</div>
<div class="info-item">
<span class="info-label">{{ 'usedPercentageLabel' | translate }}:</span>
<span class="info-value usage-percentage">{{ isRepository ? diskUsage.used_percentage : diskUsage.percentage }}%</span>
<span class="info-value usage-percentage">{{ isRepository ? diskUsage.used_percentage : diskUsage.percentage }}</span>
</div>
</div>
</div>
</div>
<!-- RAM Usage Section -->
<div class="resource-card ram-usage-container" *ngIf="isRepository">
<div class="resource-header">
<div class="resource-icon">
@ -67,13 +64,12 @@
</div>
<div class="info-item">
<span class="info-label">{{ 'usedPercentageLabel' | translate }}:</span>
<span class="info-value usage-percentage">{{ ramUsage.used_percentage }}%</span>
<span class="info-value usage-percentage">{{ ramUsage.used_percentage }}</span>
</div>
</div>
</div>
</div>
<!-- CPU Usage Section -->
<div class="resource-card cpu-usage-container" *ngIf="isRepository">
<div class="resource-header">
<div class="resource-icon">
@ -84,7 +80,7 @@
<div class="resource-content">
<div class="cpu-usage-display">
<div class="cpu-circle">
<div class="cpu-percentage">{{ cpuUsage.used_percentage }}%</div>
<div class="cpu-percentage">{{ cpuUsage.used_percentage }}</div>
<div class="cpu-label">Uso actual</div>
</div>
</div>
@ -92,9 +88,7 @@
</div>
</div>
<!-- Sección de servicios y procesos -->
<div class="services-section">
<!-- Services Status Section -->
<div class="service-card services-status" joyrideStep="servicesStatusStep" text="{{ 'servicesStatusDescription' | translate }}">
<div class="service-header">
<div class="service-icon">
@ -116,7 +110,6 @@
</div>
</div>
<!-- Processes Status Section -->
<div class="service-card processes-status" *ngIf="isRepository">
<div class="service-header">
<div class="service-icon">
@ -139,9 +132,7 @@
</div>
</div>
<!-- Sección de datos específicos -->
<div class="data-section">
<!-- Installed OgLives Section -->
<div class="data-card" *ngIf="!isRepository && !isDhcp">
<div class="data-header">
<div class="data-icon">
@ -171,7 +162,6 @@
</div>
</div>
<!-- Subnets Section -->
<div class="data-card" *ngIf="isDhcp">
<div class="data-header">
<div class="data-icon">

View File

@ -675,3 +675,340 @@ mat-form-field {
}
}
@media (max-width: 768px) {
.scroll-to-top-button {
right: 16px;
bottom: 16px;
}
}
/* Estilos para el selector de ramas */
.branch-selector-header {
display: flex;
gap: 16px;
align-items: flex-end;
width: 100%;
}
.create-branch-button {
background: #4caf50;
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
height: 56px;
}
.create-branch-button:hover:not(:disabled) {
background: #45a049;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
}
.create-branch-button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.create-branch-button mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* Estilos para la sección de commits */
.commits-section {
margin-top: 24px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
}
.search-container {
margin-bottom: 20px;
}
.search-string {
width: 100%;
}
.commits-table-container {
margin-top: 20px;
overflow-x: auto;
}
.commit-id {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: #333;
}
.commit-message {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-stats {
font-size: 12px;
color: #666;
}
.commit-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.commit-tags mat-chip {
font-size: 10px;
height: 20px;
}
.no-tags {
color: #999;
font-size: 12px;
font-style: italic;
}
.action-buttons {
display: flex;
gap: 8px;
justify-content: center;
}
.action-buttons button {
transition: all 0.2s ease;
}
.action-buttons button:hover {
transform: scale(1.1);
}
.paginator-container {
margin-top: 20px;
}
/* Responsive para ramas y commits */
@media (max-width: 768px) {
.branch-selector-header {
flex-direction: column;
align-items: stretch;
}
.create-branch-button {
height: auto;
padding: 12px 16px;
}
.commits-section {
padding: 16px;
}
.commit-message {
max-width: 200px;
}
.action-buttons {
flex-direction: column;
gap: 4px;
}
}
.git-info-container {
margin-bottom: 20px;
}
.git-info-card {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
color: #495057;
border: 1px solid #e9ecef;
animation: fadeInUp 0.5s ease-out;
}
.git-info-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.git-info-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #6c757d;
}
.git-info-title {
font-size: 16px;
font-weight: 600;
flex-grow: 1;
color: #495057;
}
.git-loading-spinner {
margin-left: auto;
}
.git-info-content {
background: #ffffff;
border-radius: 6px;
padding: 16px;
margin-top: 12px;
border: 1px solid #dee2e6;
}
.git-info-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.git-info-item {
display: flex;
flex-direction: column;
}
.git-form-field {
width: 100%;
}
.git-item-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #495057;
margin-right: 4px;
}
.git-data-display {
background: #f8f9fa;
border-radius: 4px;
padding: 12px;
margin: 0;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #495057;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
border: 1px solid #e9ecef;
}
.git-info-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-size: 14px;
color: #6c757d;
}
.git-info-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-size: 14px;
color: #6c757d;
font-style: italic;
}
/* Estilos para la advertencia de repositorio no encontrado */
.repository-warning {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 16px;
padding: 16px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
color: #856404;
}
.warning-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #f39c12;
flex-shrink: 0;
margin-top: 2px;
}
.warning-content {
flex-grow: 1;
}
.warning-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #e67e22;
}
.warning-message {
font-size: 13px;
line-height: 1.4;
color: #856404;
}
/* Responsive para el cuadro informativo de Git */
@media (max-width: 768px) {
.git-info-card {
padding: 16px;
}
.git-info-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.git-loading-spinner {
margin-left: 0;
align-self: flex-end;
}
.git-data-display {
font-size: 11px;
max-height: 200px;
}
.git-info-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.git-info-item {
padding: 10px;
}
.git-info-value {
font-size: 13px;
}
}

View File

@ -1,4 +1,5 @@
<!-- Overlay de carga para creación de repositorio -->
<app-loading [isLoading]="loading"></app-loading>
<app-modal-overlay
[isVisible]="creatingRepository"
message="Creando repositorio...">
@ -20,12 +21,11 @@
</div>
</div>
<div class="button-row">
<button class="action-button" id="execute-button" [disabled]="!selectedPartition" (click)="save()">Ejecutar</button>
<button class="action-button" id="execute-button" [disabled]="!selectedPartition || loading" (click)="save()">Ejecutar</button>
</div>
</div>
<div class="select-container">
<!-- Sección: Configuración de tipo de imagen -->
<div class="form-section">
<div class="form-section-title">
<mat-icon>settings</mat-icon>
@ -43,15 +43,81 @@
</div>
</div>
<!-- Sección: Configuración Git (solo para tipo git) -->
<div class="form-section" *ngIf="imageType === 'git'">
<div class="form-section-title">
<mat-icon>code</mat-icon>
Configuración Git
</div>
<div class="git-repository-section">
<div class="repository-header">
<div class="git-info-container" *ngIf="selectedPartition && imageType === 'git' && (hasValidGitData || loadingGitData)">
<div class="git-info-card">
<div class="git-info-header">
<mat-icon class="git-info-icon">info</mat-icon>
<span class="git-info-title">La imagen en caché a actualizar es:</span>
<mat-spinner *ngIf="loadingGitData" diameter="20" class="git-loading-spinner"></mat-spinner>
</div>
<div class="" *ngIf="!loadingGitData && hasValidGitData">
<div class="git-info-grid">
<div class="git-info-item">
<mat-form-field appearance="outline" class="git-form-field">
<mat-label>
<mat-icon class="git-item-icon">folder</mat-icon>
Repositorio
</mat-label>
<input matInput [value]="gitData.repo" readonly>
</mat-form-field>
</div>
<div class="git-info-item">
<mat-form-field appearance="outline" class="git-form-field">
<mat-label>
<mat-icon class="git-item-icon">account_tree</mat-icon>
Rama origen
</mat-label>
<input matInput [value]="gitData.branch" readonly>
</mat-form-field>
</div>
<div class="git-info-item">
<mat-form-field appearance="outline" class="git-form-field">
<mat-label>
<mat-icon class="git-item-icon">call_split</mat-icon>
Rama destino
</mat-label>
<input matInput
[(ngModel)]="destinationBranch"
[readonly]="!isDestinationBranchEditable"
placeholder="Nombre de la rama destino"
[matTooltip]="isDestinationBranchEditable ? 'Guardar cambios' : 'Editar nombre de la rama destino'">
<button mat-icon-button
matSuffix
[matTooltip]="isDestinationBranchEditable ? 'Guardar' : 'Editar rama destino'"
(click)="toggleDestinationBranchEdit()">
<mat-icon>{{ isDestinationBranchEditable ? 'save' : 'edit' }}</mat-icon>
</button>
</mat-form-field>
</div>
</div>
<div class="repository-warning" *ngIf="repositoryNotFound">
<mat-icon class="warning-icon">warning</mat-icon>
<div class="warning-content">
<div class="warning-title">Repositorio no encontrado</div>
<div class="warning-message">
El repositorio "{{ gitData.repo }}" no existe en el listado de repositorios disponibles.
Es posible que necesites crearlo primero.
</div>
</div>
</div>
</div>
<div class="git-info-loading" *ngIf="loadingGitData">
<span>Cargando información de Git...</span>
</div>
<div class="git-info-empty" *ngIf="!loadingGitData && !hasValidGitData">
<span>No se encontró información de Git para esta partición</span>
</div>
</div>
</div>
<div class="repository-header" *ngIf="!hasValidGitData">
<button *ngIf="imageType === 'git'"
class="create-repository-button"
(click)="openCreateRepositoryModal()"
@ -61,95 +127,82 @@
</button>
</div>
<div class="selector">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccionar repositorio Git</mat-label>
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositorySelected($event.value)" required>
<mat-option [value]="null">Seleccionar repositorio git / SO</mat-option>
<mat-option *ngFor="let repo of gitRepositories" [value]="repo">{{ repo.name }}</mat-option>
</mat-select>
<mat-spinner *ngIf="loadingGitRepositories" matSuffix diameter="20"></mat-spinner>
<mat-hint>
<mat-icon>info</mat-icon>
Selecciona el repositorio git para obtener las imágenes disponibles.
<span *ngIf="gitRepositories.length === 0" class="no-repositories-hint">
No hay repositorios disponibles. Crea uno nuevo para continuar.
</span>
</mat-hint>
</mat-form-field>
</div>
</div>
<div class="selector" *ngIf="!hasValidGitData">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccionar repositorio Git</mat-label>
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositorySelected($event.value)" required>
<mat-option [value]="null">Seleccionar repositorio git / SO</mat-option>
<mat-option *ngFor="let repo of gitRepositories" [value]="repo">{{ repo.name }}</mat-option>
</mat-select>
<mat-spinner *ngIf="loadingGitRepositories" matSuffix diameter="20"></mat-spinner>
<mat-hint>
<mat-icon>info</mat-icon>
Selecciona el repositorio git para obtener las imágenes disponibles.
<span *ngIf="gitRepositories.length === 0" class="no-repositories-hint">
No hay repositorios disponibles. Crea uno nuevo para continuar.
</span>
</mat-hint>
</mat-form-field>
<!-- Opciones de acción Git -->
<div class="git-action-selector">
<div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="gitAction" required class="action-chip-listbox">
<mat-chip-option value="create" class="action-chip create-chip firmware-chip" (click)="onGitActionSelected({value: 'create'})">
<span>Crear imagen</span>
</mat-chip-option>
<mat-chip-option value="update" class="action-chip update-chip firmware-chip" (click)="onGitActionSelected({value: 'update'})">
<span>Actualizar imagen</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="action-hint">
<mat-icon>info</mat-icon>
<span *ngIf="gitAction === 'create'">Crea una nueva imagen con el nombre especificado</span>
<span *ngIf="gitAction === 'update'">Actualiza una imagen existente seleccionada</span>
</div>
</div>
</div>
<!-- Sección: Configuración general -->
<div class="form-section" *ngIf="imageType !== 'git'">
<div class="form-section-title">
<mat-icon>image</mat-icon>
Configuración de imagen
<button class="create-branch-button"
*ngIf="selectedGitRepository"
(click)="openCreateBranchModal()"
[disabled]="loadingBranches"
matTooltip="Crear nueva rama"
style="display: none;">
<mat-icon>add</mat-icon>
<span>Crear rama</span>
</button>
</div>
<!-- Opciones de acción para imágenes monolíticas -->
<div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="monolithicAction" required class="action-chip-listbox">
<mat-chip-option value="create" class="action-chip create-chip firmware-chip" (click)="onMonolithicActionSelected({value: 'create'})">
<span>Crear imagen</span>
</mat-chip-option>
<mat-chip-option value="update" class="action-chip update-chip firmware-chip" (click)="onMonolithicActionSelected({value: 'update'})">
<span>Actualizar imagen</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="action-hint">
<mat-icon>info</mat-icon>
<span *ngIf="monolithicAction === 'create'">Crea una nueva imagen con el nombre especificado</span>
<span *ngIf="monolithicAction === 'update'">Actualiza una imagen existente seleccionada</span>
</div>
<div class="selector" *ngIf="monolithicAction === 'create'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Nombre canónico</mat-label>
<input matInput [(ngModel)]="name" placeholder="Nombre canónico. En minúscula y sin espacios" required>
<mat-hint>Introduce el nombre para la nueva imagen que se creará.</mat-hint>
</mat-form-field>
</div>
<div class="selector" *ngIf="monolithicAction === 'update'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" (selectionChange)="resetCanonicalName()" required>
<mat-option [value]="null">Seleccionar imagen para actualizar</mat-option>
<mat-option *ngFor="let image of images" [value]="image">{{ image?.name }}</mat-option>
</mat-select>
<button *ngIf="selectedImage" mat-icon-button matSuffix aria-label="Clear client search"
(click)="selectedImage = null; resetCanonicalName()">
<mat-icon>close</mat-icon>
</button>
<mat-hint>Selecciona la imagen existente que quieres actualizar.</mat-hint>
</mat-form-field>
</div>
</div>
<!-- Sección: Selección de partición -->
<div class="form-section" *ngIf="imageType === 'monolithic'">
<div class="form-section-title">
<mat-icon>image</mat-icon>
Configuración de imagen monolítica
</div>
<div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="monolithicAction" required class="action-chip-listbox">
<mat-chip-option value="create" class="action-chip create-chip firmware-chip" (click)="onMonolithicActionSelected({value: 'create'})">
<span>Crear imagen</span>
</mat-chip-option>
<mat-chip-option value="update" class="action-chip update-chip firmware-chip" (click)="onMonolithicActionSelected({value: 'update'})">
<span>Actualizar imagen</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="action-hint">
<mat-icon>info</mat-icon>
<span *ngIf="monolithicAction === 'create'">Crea una nueva imagen con el nombre especificado</span>
<span *ngIf="monolithicAction === 'update'">Actualiza una imagen existente seleccionada</span>
</div>
<div class="selector" *ngIf="monolithicAction === 'create'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Nombre canónico</mat-label>
<input matInput [(ngModel)]="name" placeholder="Nombre canónico. En minúscula y sin espacios" required>
<mat-hint>Introduce el nombre para la nueva imagen que se creará.</mat-hint>
</mat-form-field>
</div>
<div class="selector" *ngIf="monolithicAction === 'update'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" (selectionChange)="resetCanonicalName()" required>
<mat-option [value]="null">Seleccionar imagen para actualizar</mat-option>
<mat-option *ngFor="let image of images" [value]="image">{{ image?.name }}</mat-option>
</mat-select>
<button *ngIf="selectedImage" mat-icon-button matSuffix aria-label="Clear client search"
(click)="selectedImage = null; resetCanonicalName()">
<mat-icon>close</mat-icon>
</button>
<mat-hint>Selecciona la imagen existente que quieres actualizar.</mat-hint>
</mat-form-field>
</div>
</div>
<div class="form-section" #partitionSection id="partition-selection">
<div class="form-section-title">
<mat-icon>storage</mat-icon>

View File

@ -8,6 +8,7 @@ import { ConfigService } from '@services/config.service';
import {MatDialog} from "@angular/material/dialog";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
import {CreateRepositoryModalComponent} from "./create-repository-modal/create-repository-modal.component";
import {CreateBranchModalComponent} from "../../../../repositories/show-git-images/create-branch-modal/create-branch-modal.component";
@Component({
selector: 'app-create-image',
@ -38,12 +39,27 @@ export class CreateClientImageComponent implements OnInit{
loadingGitRepositories: boolean = false;
loadingGitImageRepositories: boolean = false;
creatingRepository: boolean = false;
gitAction: string = 'create';
monolithicAction: string = 'create';
existingImages: any[] = [];
selectedExistingImage: any = null;
loadingExistingImages: boolean = false;
dataSource = new MatTableDataSource<any>();
branches: string[] = [];
selectedBranch: string = '';
loadingBranches: boolean = false;
gitData: any = null;
loadingGitData: boolean = false;
destinationBranch: string = '';
isDestinationBranchEditable: boolean = false;
repositoryNotFound: boolean = false;
newlyCreatedRepository: any = null;
get hasValidGitData(): boolean {
return this.gitData && this.gitData.repo && this.gitData.branch;
}
columns = [
{
columnDef: 'diskNumber',
@ -107,9 +123,19 @@ export class CreateClientImageComponent implements OnInit{
this.clientName = response.name;
this.selectedRepository = response.repository;
this.dataSource.data = response.partitions.filter((partition: any) => {
const validPartitions = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
});
this.dataSource.data = validPartitions;
const firstValidPartition = validPartitions.find((partition: any) => {
return partition.operativeSystem;
});
if (firstValidPartition) {
this.selectedPartition = firstValidPartition;
}
}
},
(error) => {
@ -141,6 +167,23 @@ export class CreateClientImageComponent implements OnInit{
(response: any) => {
this.gitRepositories = response['hydra:member'];
this.loadingGitRepositories = false;
if (this.newlyCreatedRepository) {
const newRepo = this.gitRepositories.find(repo =>
repo.name === this.newlyCreatedRepository.name
);
if (newRepo) {
this.selectedGitRepository = newRepo;
this.onGitRepositorySelected(newRepo);
this.toastService.success(`Repositorio "${newRepo.name}" preseleccionado`);
}
this.newlyCreatedRepository = null;
}
this.checkRepositoryExists();
},
(error) => {
console.error('Error al cargar los repositorios git:', error);
@ -151,7 +194,7 @@ export class CreateClientImageComponent implements OnInit{
loadGitImageRepositories(gitRepository: any) {
this.loadingGitImageRepositories = true;
const url = `${this.baseUrl}/git-image-repositories?gitRepository.id=${gitRepository.id}&page=1&itemsPerPage=100`;
const url = `${this.baseUrl}/git-image-repositories?gitRepository.uuid=${gitRepository.uuid}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
(response: any) => {
this.gitImageRepositories = response['hydra:member'];
@ -168,47 +211,97 @@ export class CreateClientImageComponent implements OnInit{
this.selectedGitRepository = gitRepository;
this.selectedExistingImage = null;
this.existingImages = [];
this.selectedBranch = '';
this.branches = [];
if (gitRepository) {
this.loadGitImageRepositories(gitRepository);
this.loadBranches();
} else {
this.gitImageRepositories = [];
}
}
onGitActionSelected(event: any) {
console.log('onGitActionSelected llamado con:', event);
this.gitAction = event.value;
this.selectedExistingImage = null;
this.gitImageName = '';
// Si se selecciona 'update' y ya hay un repositorio Git seleccionado, cargar los repositorios de imágenes
if (event.value === 'update' && this.selectedGitRepository) {
this.loadGitImageRepositories(this.selectedGitRepository);
loadBranches(): void {
if (!this.selectedGitRepository) {
return;
}
console.log('Antes del setTimeout');
// Hacer scroll hacia la sección de partición después de un delay más largo
setTimeout(() => {
console.log('Dentro del setTimeout, llamando a scrollToPartitionSection');
this.scrollToPartitionSection();
}, 300);
this.loadingBranches = true;
const url = `${this.baseUrl}/image-repositories/server/git/${this.selectedRepository.uuid}/branches`;
this.http.post<any>(url, { repositoryName: this.selectedGitRepository.name }).subscribe(
data => {
this.branches = data.branches || [];
this.loadingBranches = false;
if (this.branches.length > 0) {
this.selectedBranch = this.branches[0];
}
},
error => {
console.error('Error fetching branches', error);
this.toastService.error('Error al cargar las ramas del repositorio');
this.loadingBranches = false;
}
);
}
onBranchChange(): void {
// La rama ha sido seleccionada, no necesitamos hacer nada más
}
openCreateBranchModal(): void {
if (this.hasValidGitData) {
const dialogRef = this.dialog.open(CreateBranchModalComponent, {
width: '500px',
data: {
commit: this.gitData.branch,
repositoryName: this.gitData.repo,
repositoryUuid: this.selectedRepository.uuid
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.toastService.success('Rama creada correctamente');
this.destinationBranch = result.branchName || result;
this.loadBranches();
}
});
return;
}
if (!this.selectedGitRepository) {
this.toastService.error('Debe seleccionar un repositorio primero');
return;
}
const dialogRef = this.dialog.open(CreateBranchModalComponent, {
width: '500px',
data: {
commit: this.selectedBranch || 'master',
repositoryName: this.selectedGitRepository.name,
repositoryUuid: this.selectedRepository.uuid
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.toastService.success('Rama creada correctamente');
this.destinationBranch = result.branchName || result;
this.loadBranches();
}
});
}
onMonolithicActionSelected(event: any) {
console.log('onMonolithicActionSelected llamado con:', event);
this.monolithicAction = event.value;
this.selectedImage = null;
this.name = '';
// Si se selecciona 'update', cargar las imágenes existentes
if (event.value === 'update') {
this.loadImages();
}
console.log('Antes del setTimeout (monolithic)');
// Hacer scroll hacia la sección de partición después de un delay más largo
setTimeout(() => {
console.log('Dentro del setTimeout (monolithic), llamando a scrollToPartitionSection');
this.scrollToPartitionSection();
}, 300);
}
@ -217,8 +310,7 @@ export class CreateClientImageComponent implements OnInit{
if (!this.selectedExistingImage) return;
this.loadingExistingImages = true;
// Aquí deberías hacer el GET al endpoint externo
// Por ahora uso un endpoint de ejemplo, ajusta según tu API
const url = `${this.baseUrl}/images?gitImageRepository.id=${this.selectedExistingImage.id}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
@ -238,14 +330,15 @@ export class CreateClientImageComponent implements OnInit{
this.selectedGitRepository = null;
this.selectedExistingImage = null;
this.gitImageName = '';
this.gitAction = 'create';
this.monolithicAction = 'create';
this.existingImages = [];
this.gitRepositories = [];
this.gitImageRepositories = [];
this.selectedBranch = '';
this.branches = [];
this.selectedImage = null;
this.name = '';
this.monolithicAction = 'create';
}
resetCanonicalName() {
@ -258,16 +351,6 @@ export class CreateClientImageComponent implements OnInit{
return;
}
if (this.imageType === 'git') {
if (!this.selectedGitRepository) {
this.toastService.error('Debes seleccionar un repositorio Git');
return;
}
if (this.gitAction === 'update' && !this.selectedExistingImage) {
this.toastService.error('Debes seleccionar un repositorio de imágenes Git');
return;
}
}
if (this.imageType === 'monolithic') {
if (this.monolithicAction === 'create' && !this.name) {
@ -295,24 +378,46 @@ export class CreateClientImageComponent implements OnInit{
if (result !== undefined) {
this.loading = true;
let payload: any = {
client: `/clients/${this.clientId}`,
partition: this.selectedPartition['@id'],
source: 'assistant',
type: this.imageType,
queue: result
};
let endpoint: string;
let payload: any;
if (this.imageType === 'git') {
payload.gitRepository = this.selectedGitRepository.name
payload.name = this.selectedGitRepository.name;
const gitRepoName = this.hasValidGitData ? this.gitData.repo : this.selectedGitRepository?.name;
const originBranch = this.hasValidGitData ? this.gitData.branch : this.selectedBranch;
if (this.gitAction === 'create') {
payload.action = 'create';
if (this.branches.length === 0) {
endpoint = `${this.baseUrl}/images`;
payload = {
client: `/clients/${this.clientId}`,
partition: this.selectedPartition['@id'],
type: this.imageType,
gitRepository: gitRepoName,
name: gitRepoName,
originalBranch: originBranch,
destinationBranch: this.destinationBranch,
action: 'create',
queue: result
};
} else {
payload.action = 'update';
endpoint = `${this.baseUrl}/git-repositories/update-image`;
payload = {
client: `/clients/${this.clientId}`,
partition: this.selectedPartition['@id'],
gitRepository: gitRepoName,
originalBranch: originBranch,
destinationBranch: this.destinationBranch,
queue: result
};
}
} else {
endpoint = `${this.baseUrl}/images`;
payload = {
client: `/clients/${this.clientId}`,
partition: this.selectedPartition['@id'],
type: this.imageType,
queue: result
};
if (this.monolithicAction === 'create') {
payload.name = this.name;
payload.action = 'create';
@ -322,13 +427,13 @@ export class CreateClientImageComponent implements OnInit{
}
}
this.http.post(`${this.baseUrl}/images`, payload)
this.http.post(endpoint, payload)
.subscribe({
next: (response) => {
let actionText = 'creación';
if (this.imageType === 'git' && this.gitAction === 'update') {
if (this.imageType === 'monolithic' && this.monolithicAction === 'update') {
actionText = 'actualización';
} else if (this.imageType === 'monolithic' && this.monolithicAction === 'update') {
} else if (this.imageType === 'git' && this.branches.length > 0) {
actionText = 'actualización';
}
this.toastService.success(`Petición de ${actionText} de imagen enviada`);
@ -359,14 +464,28 @@ export class CreateClientImageComponent implements OnInit{
dialogRef.afterClosed().subscribe(result => {
this.creatingRepository = false;
if (result) {
this.newlyCreatedRepository = result;
this.loadGitRepositories();
setTimeout(() => {
const newRepository = this.gitRepositories.find(repo => repo['@id'] === result['@id']);
if (newRepository) {
this.selectedGitRepository = newRepository;
this.onGitRepositorySelected(newRepository);
if (this.newlyCreatedRepository && !this.selectedGitRepository) {
const newRepo = this.gitRepositories.find(repo =>
repo.name === this.newlyCreatedRepository.name
);
if (newRepo) {
this.selectedGitRepository = newRepo;
this.onGitRepositorySelected(newRepo);
this.toastService.success(`Repositorio "${newRepo.name}" preseleccionado`);
}
this.newlyCreatedRepository = null;
}
}, 200);
}, 1000);
} else {
}
});
}
@ -383,8 +502,13 @@ export class CreateClientImageComponent implements OnInit{
this.selectedImage = null;
this.name = '';
this.monolithicAction = 'create';
if (this.selectedPartition) {
this.loadGitData(this.selectedPartition);
}
} else {
this.resetGitSelections();
this.gitData = null;
}
}
@ -414,5 +538,82 @@ export class CreateClientImageComponent implements OnInit{
set selectedPartition(value: any) {
this._selectedPartition = value;
if (value && this.imageType === 'git') {
this.loadGitData(value);
} else {
this.gitData = null;
}
}
loadGitData(partition: any): void {
this.loadingGitData = true;
this.gitData = null;
this.repositoryNotFound = false;
const payload = {
partition: partition['@id'],
client: `/clients/${this.clientId}`
};
this.http.post(`${this.baseUrl}/git-repositories/get-git-data`, payload).subscribe({
next: (response) => {
this.gitData = response;
if (this.gitData && this.gitData.branch) {
this.destinationBranch = this.gitData.branch;
}
this.loadingGitData = false;
this.checkRepositoryExists();
if (this.hasValidGitData) {
this.loadBranchesForGitData();
}
},
error: (error) => {
console.error('Error al cargar datos de Git:', error);
this.toastService.error('Error al cargar información de Git');
this.loadingGitData = false;
}
});
}
checkRepositoryExists(): void {
if (this.gitData && this.gitData.repo && this.gitRepositories.length > 0) {
const repoExists = this.gitRepositories.some((repo: any) =>
repo.name === this.gitData.repo
);
this.repositoryNotFound = !repoExists;
} else {
this.repositoryNotFound = false;
}
}
loadBranchesForGitData(): void {
if (!this.gitData || !this.gitData.repo) {
return;
}
this.loadingBranches = true;
const url = `${this.baseUrl}/image-repositories/server/git/${this.selectedRepository.uuid}/branches`;
this.http.post<any>(url, { repositoryName: this.gitData.repo }).subscribe(
data => {
this.branches = data.branches || [];
this.loadingBranches = false;
},
error => {
console.error('Error fetching branches for Git data', error);
this.loadingBranches = false;
}
);
}
toggleDestinationBranchEdit(): void {
this.isDestinationBranchEditable = !this.isDestinationBranchEditable;
if (!this.isDestinationBranchEditable) {
// Opcional: Aquí se pueden agregar validaciones adicionales
console.log('Rama destino guardada:', this.destinationBranch);
}
}
}

View File

@ -519,4 +519,73 @@ table {
}
}
.commits-table-container {
margin-top: 20px;
}
::ng-deep .mat-chip.mat-standard-chip.mat-chip-selected.mat-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
border-radius: 20px !important;
font-weight: 500 !important;
font-size: 12px !important;
padding: 8px 16px !important;
margin: 2px !important;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3) !important;
transition: all 0.3s ease !important;
border: none !important;
}
::ng-deep .mat-chip.mat-standard-chip.mat-chip-selected.mat-primary:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
}
::ng-deep .mat-chip.mat-standard-chip.mat-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
border-radius: 20px !important;
font-weight: 500 !important;
font-size: 12px !important;
padding: 8px 16px !important;
margin: 2px !important;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3) !important;
transition: all 0.3s ease !important;
border: none !important;
}
::ng-deep .mat-chip.mat-standard-chip.mat-primary:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
}
::ng-deep .custom-tag-chip.mat-chip.mat-standard-chip.mat-chip-selected.mat-primary {
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;
transition: all 0.3s ease !important;
}
::ng-deep .custom-tag-chip.mat-chip.mat-standard-chip.mat-chip-selected.mat-primary:hover {
background: rgba(102, 126, 234, 0.15) !important;
border-color: rgba(102, 126, 234, 0.5) !important;
transform: translateY(-1px) !important;
}
.no-tags-text {
color: #999;
font-style: italic;
font-size: 12px;
padding: 4px 8px;
background: #f8f9fa;
border-radius: 12px;
border: 1px dashed #dee2e6;
}

View File

@ -129,11 +129,18 @@
</mat-select>
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
</mat-form-field>
<mat-form-field appearance="fill" style="width: 200px;">
<mat-label>Tag</mat-label>
<mat-select [(ngModel)]="showOnlyTagged" (selectionChange)="onTagFilterChange()" [disabled]="!commits.length">
<mat-option [value]="false">No</mat-option>
<mat-option [value]="true"></mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Tabla de commits (solo si imageType === 'git') -->
<div *ngIf="imageType === 'git' && commits.length > 0" class="commits-table-container">
<table mat-table [dataSource]="commits" class="mat-elevation-z8">
<div *ngIf="imageType === 'git' && filteredCommits.length > 0" class="commits-table-container">
<table mat-table [dataSource]="filteredCommits" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>Seleccionar</th>
<td mat-cell *matCellDef="let commit">
@ -162,8 +169,11 @@
<th mat-header-cell *matHeaderCellDef>Tags</th>
<td mat-cell *matCellDef="let commit">
<mat-chip-list>
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected>{{ tag }}</mat-chip>
<span *ngIf="!commit.tags || commit.tags.length === 0" style="color: #999; font-style: italic;">Sin tags</span>
<mat-chip *ngFor="let tag of commit.tags" 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">Sin tags</span>
</mat-chip-list>
</td>
</ng-container>
@ -172,7 +182,6 @@
</table>
</div>
<!-- Selector de método y de imagen solo si imageType === 'monolithic' -->
<div class="monolithic-row" *ngIf="imageType === 'monolithic'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccione método de deploy</mat-label>

View File

@ -109,6 +109,9 @@ export class DeployImageComponent implements OnInit{
selectedCommit: any = null;
private initialGitLoad = true;
showOnlyTagged: boolean = false;
filteredCommits: any[] = [];
constructor(
private http: HttpClient,
private toastService: ToastrService,
@ -387,7 +390,7 @@ export class DeployImageComponent implements OnInit{
}
if (this.imageType === 'git') {
if (!this.selectedCommit) {
if (!this.selectedCommit || this.filteredCommits.length === 0) {
return false;
}
}
@ -418,17 +421,28 @@ export class DeployImageComponent implements OnInit{
openScheduleModal(): void {
let scope = this.runScriptContext.type;
let selectedClients = null;
if ((!this.runScriptContext || this.runScriptContext.type === 'client' || this.selectedClients.length === 1) && this.selectedClients && this.selectedClients.length > 0) {
scope = 'clients';
selectedClients = this.selectedClients;
}
const dialogRef = this.dialog.open(CreateTaskComponent, {
width: '800px',
data: {
scope: this.runScriptContext.type,
organizationalUnit: this.runScriptContext['@id'],
source: 'assistant'
scope: scope,
selectedClients: selectedClients,
organizationalUnit: this.runScriptContext?.['@id'],
source: 'assistant',
runScriptContext: this.runScriptContext
}
});
dialogRef.afterClosed().subscribe((result: { [x: string]: any; }) => {
if (result) {
if (result !== undefined) {
const payload = {
method: this.selectedMethod,
diskNumber: this.selectedPartition.diskNumber,
@ -519,7 +533,9 @@ export class DeployImageComponent implements OnInit{
this.selectedBranch = '';
this.branches = [];
this.commits = [];
this.filteredCommits = [];
this.selectedCommit = null;
this.showOnlyTagged = false;
this.loadGitBranches();
}
@ -545,6 +561,8 @@ export class DeployImageComponent implements OnInit{
onGitBranchChange() {
this.selectedCommit = null;
this.commits = [];
this.filteredCommits = [];
this.showOnlyTagged = false;
this.loadGitCommits();
}
@ -558,6 +576,12 @@ export class DeployImageComponent implements OnInit{
data => {
this.commits = data.commits || [];
this.loadingCommits = false;
this.filterCommits();
if (this.filteredCommits.length > 0) {
this.selectedCommit = this.filteredCommits.reduce((a, b) =>
new Date(a.committed_date * 1000) > new Date(b.committed_date * 1000) ? a : b
);
}
},
error => {
this.toastService.error('Error al cargar los commits');
@ -565,4 +589,23 @@ export class DeployImageComponent implements OnInit{
}
);
}
filterCommits() {
if (this.showOnlyTagged) {
this.filteredCommits = this.commits.filter(commit =>
commit.tags && commit.tags.length > 0
);
} else {
this.filteredCommits = this.commits;
}
}
onTagFilterChange() {
this.filterCommits();
if (this.filteredCommits.length > 0 && !this.filteredCommits.includes(this.selectedCommit)) {
this.selectedCommit = this.filteredCommits.reduce((a, b) =>
new Date(a.committed_date * 1000) > new Date(b.committed_date * 1000) ? a : b
);
}
}
}

View File

@ -175,6 +175,22 @@
margin-bottom: 20px;
}
.selected-disk-hint {
display: flex;
align-items: center;
gap: 8px;
color: #2e7d32;
font-weight: 500;
font-size: 14px;
}
.hint-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #4caf50;
}
/* Opciones del select */
::ng-deep .disk-option {
display: flex;
@ -232,7 +248,9 @@
background: #e8f5e8;
border: 1px solid #c8e6c9;
border-radius: 12px;
margin-top: 16px;
margin-top: 0;
flex-shrink: 0;
min-width: 250px;
}
.info-icon {
@ -709,8 +727,454 @@
margin: 5px 0 !important;
}
/* ===== ESTADOS DE ADVERTENCIA ===== */
/* Advertencia (90% a 99% usado) */
/* ===== LAYOUT PRINCIPAL ===== */
.partition-layout {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 20px;
}
/* ===== BARRA DE PROGRESO DE PARTICIONES ===== */
.partition-progress-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
padding: 24px;
margin-bottom: 20px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.progress-header h3 {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 600;
}
.disk-info-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.disk-info-summary span {
padding: 6px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.total-size {
background: #e3f2fd;
color: #1976d2;
}
.used-size {
background: #fff3e0;
color: #f57c00;
}
.free-size {
background: #e8f5e8;
color: #388e3c;
}
.partition-progress-bar {
margin-bottom: 20px;
}
.progress-segments {
display: flex;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #e0e0e0;
background: #f5f5f5;
}
.progress-segment {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
transition: all 0.3s ease;
cursor: pointer;
border-right: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.progress-segment:hover {
filter: brightness(1.2);
transform: scale(1.02);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 2;
}
.progress-segment:last-child {
border-right: none;
}
.progress-segment.removed {
opacity: 0.3;
background: #ccc !important;
}
.progress-segment.free-space {
background: linear-gradient(45deg, #e8f5e8, #c8e6c9);
border-left: 1px solid #e0e0e0;
}
.segment-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: white;
font-weight: 600;
font-size: 12px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
}
.partition-number {
font-size: 14px;
font-weight: bold;
}
.partition-percentage {
font-size: 10px;
opacity: 0.9;
}
.free-label {
font-size: 12px;
font-weight: bold;
}
.free-percentage {
font-size: 10px;
opacity: 0.9;
}
/* ===== LEYENDA DE PARTICIONES ===== */
.partition-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 20px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
border: 1px solid #e9ecef;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: white;
border-radius: 8px;
border: 1px solid #e0e0e0;
font-size: 14px;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.legend-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.legend-item.removed {
opacity: 0.5;
text-decoration: line-through;
}
.legend-color {
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.legend-item:hover .legend-color {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.legend-color.free-color {
background: linear-gradient(45deg, #e8f5e8, #c8e6c9);
}
.legend-text {
font-weight: 600;
color: #333;
font-size: 14px;
}
.legend-size {
font-size: 12px;
color: #666;
font-weight: 500;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #e9ecef;
}
/* ===== TABLA MAT-TABLE ===== */
.table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
overflow: hidden;
}
.table-header {
padding: 20px 24px 16px 24px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
}
.table-header h3 {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 600;
}
.partition-mat-table {
width: 100%;
background: white;
}
.partition-mat-table .mat-header-cell {
background: #f8f9fa;
color: #495057;
font-weight: 600;
font-size: 14px;
padding: 16px 12px;
border-bottom: 2px solid #e9ecef;
}
.partition-mat-table .mat-cell {
padding: 12px;
border-bottom: 1px solid #f1f3f4;
vertical-align: middle;
}
.partition-mat-table .mat-row:hover {
background: #f8f9fa;
}
/* Campos compactos para la tabla */
.compact-form-field {
width: 100%;
margin: 0;
}
.compact-form-field .mat-form-field-wrapper {
padding-bottom: 0;
margin: 0;
}
.compact-form-field .mat-form-field-infix {
padding: 2px 0;
min-height: 20px;
font-size: 13px;
}
.compact-form-field .mat-form-field-outline {
border-radius: 4px;
}
.compact-form-field .mat-form-field-outline-start,
.compact-form-field .mat-form-field-outline-end {
border-width: 1px;
}
.compact-form-field .mat-form-field-outline-gap {
border-width: 1px;
}
/* Estilos para inputs y selects compactos */
.compact-form-field input,
.compact-form-field .mat-select {
line-height: 1.2;
}
.compact-form-field .mat-select-value {
color: #333;
}
/* Reducir el padding de las celdas de la tabla */
.partition-mat-table .mat-cell {
padding: 6px 6px;
vertical-align: middle;
}
.partition-mat-table .mat-header-cell {
padding: 10px 6px;
}
/* Hacer los inputs más pequeños */
.compact-form-field .mat-form-field-infix {
padding: 2px 0;
min-height: 20px;
}
/* Ajustar el tamaño de los selects */
.compact-form-field .mat-select-trigger {
height: 20px;
}
/* Ajustar el tamaño de los inputs numéricos */
.compact-form-field input[type="number"] {
padding: 2px 6px;
}
/* Reducir el espacio del wrapper del form field */
.compact-form-field .mat-form-field-wrapper {
padding-bottom: 0;
margin: 0;
line-height: 1.2;
}
/* Ajustar el espacio del outline */
.compact-form-field .mat-form-field-outline {
top: 0;
bottom: 0;
}
.compact-form-field .mat-form-field-outline-start,
.compact-form-field .mat-form-field-outline-end,
.compact-form-field .mat-form-field-outline-gap {
border-width: 1px;
}
/* Checkbox en la tabla */
.partition-mat-table .mat-checkbox {
margin: 0;
}
/* Botón de eliminar */
.partition-mat-table .mat-icon-button {
width: 32px;
height: 32px;
line-height: 32px;
}
.partition-mat-table .mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.partition-layout {
gap: 16px;
}
.progress-header {
flex-direction: column;
align-items: flex-start;
}
.disk-info-summary {
width: 100%;
justify-content: space-between;
}
.partition-legend {
flex-direction: column;
gap: 8px;
}
.legend-item {
justify-content: space-between;
}
.partition-mat-table {
font-size: 12px;
}
.compact-form-field {
font-size: 12px;
}
}
@media (max-width: 480px) {
.progress-segments {
height: 40px;
}
.segment-label {
font-size: 10px;
}
.partition-number {
font-size: 12px;
}
.partition-percentage {
font-size: 8px;
}
.partition-mat-table .mat-header-cell,
.partition-mat-table .mat-cell {
padding: 8px 6px;
}
}
.progress-segment {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scaleX(0);
}
to {
opacity: 1;
transform: scaleX(1);
}
}
.legend-item {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.warning {
color: #ff9800 !important;
}
@ -723,7 +1187,6 @@
color: #ff9800 !important;
}
/* Peligro (100% o más usado) */
.danger {
color: #f44336 !important;
font-weight: bold !important;
@ -751,7 +1214,6 @@
}
}
/* ===== INSTRUCCIONES ===== */
.instructions-box {
margin-top: 15px;
background-color: #f5f5f5;
@ -780,7 +1242,6 @@
line-height: 1.5;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
@ -844,6 +1305,73 @@
}
}
.partition-validation-indicator {
margin: 16px 0;
padding: 16px 20px;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.validation-status {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
font-weight: 500;
padding: 12px 16px;
border-radius: 8px;
border: 2px solid;
}
.validation-status.loading {
color: #1976d2;
background: #e3f2fd;
border-color: #bbdefb;
}
.validation-status.success {
color: #2e7d32;
background: #e8f5e8;
border-color: #4caf50;
}
.validation-status.error {
color: #d32f2f;
background: #ffebee;
border-color: #f44336;
}
.validation-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.validation-icon.loading {
color: #1976d2;
animation: spin 1s linear infinite;
}
.validation-icon.success {
color: #2e7d32;
}
.validation-icon.error {
color: #d32f2f;
}
.validation-message {
flex: 1;
margin-left: 8px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@ -16,7 +16,7 @@
</div>
</div>
<div class="button-row">
<button class="action-button" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="save()">Ejecutar</button>
<button class="action-button" [disabled]="!selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0 || partitionValidationStatus === 'error'" (click)="save()">Ejecutar</button>
</div>
<div class="button-row">
@ -26,7 +26,7 @@
</div>
<div class="button-row">
<button class="action-button" color="accent" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="openScheduleModal()">
<button class="action-button" color="accent" [disabled]="!selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="openScheduleModal()">
Opciones de programación
</button>
</div>
@ -51,7 +51,7 @@
<div class="clients-grid">
<div *ngFor="let client of clientData" class="client-item">
<div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above"
@ -110,17 +110,13 @@
</div>
</mat-option>
</mat-select>
<mat-hint>Selecciona el disco que deseas particionar</mat-hint>
<mat-hint *ngIf="!selectedDisk">Selecciona el disco que deseas particionar</mat-hint>
<mat-hint *ngIf="selectedDisk" class="selected-disk-hint">
<mat-icon class="hint-icon">check_circle</mat-icon>
Disco {{ selectedDisk.diskNumber }} seleccionado - {{ (selectedDisk.totalDiskSize / 1024).toFixed(2) }} GB
</mat-hint>
</mat-form-field>
<div class="selection-info" *ngIf="selectedDisk">
<mat-icon class="info-icon">info</mat-icon>
<div class="info-text">
<span class="info-title">Disco seleccionado: {{ selectedDisk.diskNumber }}</span>
<span class="info-subtitle">Tamaño total: {{ (selectedDisk.totalDiskSize / 1024).toFixed(2) }} GB</span>
</div>
</div>
<div class="no-disks-message" *ngIf="!disks || disks.length === 0">
<mat-icon class="warning-icon">warning</mat-icon>
<div class="message-text">
@ -207,6 +203,16 @@
</div>
</div>
<!-- Indicador de validación de particiones -->
<div class="partition-validation-indicator" *ngIf="selectedDisk && selectedDisk.partitions.length > 0">
<div class="validation-status" [ngClass]="partitionValidationStatus">
<mat-icon *ngIf="partitionValidationStatus === 'loading'" class="validation-icon loading">hourglass_empty</mat-icon>
<mat-icon *ngIf="partitionValidationStatus === 'success'" class="validation-icon success">check_circle</mat-icon>
<mat-icon *ngIf="partitionValidationStatus === 'error'" class="validation-icon error">error</mat-icon>
<span class="validation-message" *ngIf="partitionValidationMessage">{{ partitionValidationMessage }}</span>
</div>
</div>
<table class="partition-table" id="partition-table">
<thead>
<tr>
@ -256,18 +262,21 @@
</table>
</div>
<div class="chart-container" *ngIf="selectedDisk">
<div class="chart-container" *ngIf="selectedDisk" #chartContainer>
<div class="chart-header">
<h3>Distribución de Particiones</h3>
</div>
<ngx-charts-pie-chart
[results]="selectedDisk.chartData"
[doughnut]="true"
[gradient]="true"
[labels]="true"
[tooltipDisabled]="false"
[animations]="true">
</ngx-charts-pie-chart>
<div class="chart-wrapper">
<ngx-charts-pie-chart
[results]="selectedDisk.chartData"
[doughnut]="true"
[gradient]="true"
[labels]="true"
[tooltipDisabled]="false"
[animations]="true"
[view]="view">
</ngx-charts-pie-chart>
</div>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
import {Component, EventEmitter, Inject, Input, OnInit, Output} from '@angular/core';
import {Component, EventEmitter, Inject, Input, OnInit, Output, AfterViewInit, OnDestroy, ViewChild, ElementRef, HostListener} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import {ActivatedRoute, Router} from "@angular/router";
@ -8,6 +8,9 @@ import { ConfigService } from '@services/config.service';
import {CreateTaskComponent} from "../../../../commands/commands-task/create-task/create-task.component";
import {MatDialog} from "@angular/material/dialog";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
import { Subject } from 'rxjs';
import { takeUntil, debounceTime } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
interface Partition {
uuid?: string;
@ -28,7 +31,7 @@ interface Partition {
templateUrl: './partition-assistant.component.html',
styleUrls: ['./partition-assistant.component.css']
})
export class PartitionAssistantComponent implements OnInit{
export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDestroy{
baseUrl: string;
private apiUrl: string;
@Output() dataChange = new EventEmitter<any>();
@ -47,6 +50,10 @@ export class PartitionAssistantComponent implements OnInit{
runScriptContext: any = null;
showInstructions = false;
@ViewChild('chartContainer', { static: false }) chartContainer!: ElementRef;
private destroy$ = new Subject<void>();
private resizeSubject = new Subject<void>();
view: [number, number] = [300, 200];
showLegend = true;
showLabels = true;
@ -55,6 +62,23 @@ export class PartitionAssistantComponent implements OnInit{
selectedModelClient: any = null;
partitionCode: string = '';
generatedInstructions: string = '';
// Propiedades para validación de particiones
partitionValidationStatus: 'idle' | 'loading' | 'success' | 'error' = 'idle';
partitionValidationMessage: string = '';
private validationDebounceTime = 500; // ms
private validationSubject = new Subject<void>();
// Columnas para mat-table
displayedColumns: string[] = ['partitionNumber', 'partitionCode', 'filesystem', 'size', 'percentage', 'format', 'actions'];
// Paleta de colores para las particiones
private partitionColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2',
'#A8E6CF', '#FFD3B6', '#FFAAA5', '#DCEDC8', '#FFE0B2'
];
constructor(
private http: HttpClient,
@ -77,10 +101,22 @@ export class PartitionAssistantComponent implements OnInit{
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.selectedClients = this.clientData.filter(
(client: { selected: boolean; status: string }) => client.selected
);
if (this.selectedClients.length === 0 && this.clientData.length > 0) {
this.selectedClients = [this.clientData[0]];
this.clientData[0].selected = true;
}
this.selectedModelClient = this.clientData.find((client: { selected: boolean; status: string}) => client.selected) || null;
if (!this.selectedModelClient && this.clientData.length > 0) {
this.selectedModelClient = this.clientData[0];
this.clientData[0].selected = true;
}
if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient);
}
@ -90,6 +126,55 @@ export class PartitionAssistantComponent implements OnInit{
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
this.resizeSubject.pipe(
takeUntil(this.destroy$),
debounceTime(100)
).subscribe(() => {
this.resizeChart();
});
this.validationSubject.pipe(
takeUntil(this.destroy$),
debounceTime(this.validationDebounceTime)
).subscribe(() => {
this.validatePartitionSizes();
});
}
ngAfterViewInit(): void {
setTimeout(() => {
this.resizeChart();
}, 100);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@HostListener('window:resize')
onResize(): void {
this.resizeSubject.next();
}
private resizeChart(): void {
if (this.chartContainer && this.chartContainer.nativeElement) {
const container = this.chartContainer.nativeElement;
const width = container.offsetWidth;
if (width > 0) {
const height = Math.max(250, Math.min(450, width * 0.7));
if (Math.abs(this.view[0] - width) > 10 || Math.abs(this.view[1] - height) > 10) {
this.view = [width, height];
setTimeout(() => {
this.view = [...this.view];
}, 10);
}
}
}
}
get selectedDisk():any {
@ -135,7 +220,6 @@ export class PartitionAssistantComponent implements OnInit{
initializeDisks() {
this.disks = [];
// Verificar que hay datos válidos
if (!this.data || !this.data.partitions || !Array.isArray(this.data.partitions)) {
console.warn('No hay datos de particiones válidos');
return;
@ -161,10 +245,10 @@ export class PartitionAssistantComponent implements OnInit{
size: this.convertBytesToGB(partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 512 : partition.size),
memoryUsage: partition.memoryUsage,
partitionCode: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 'EFI' : partition.partitionCode,
filesystem: partition.filesystem,
filesystem: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 'FAT32' : partition.filesystem,
sizeBytes: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 512 : partition.size,
format: false,
color: '#1f1b91',
color: this.getColorForPartition(partition.partitionNumber),
percentage: 0,
removed: false
});
@ -234,7 +318,14 @@ export class PartitionAssistantComponent implements OnInit{
}
updateSelectedClients() {
this.selectedClients = this.clientData.filter((client: { selected: any; }) => client.selected);
this.selectedClients = this.clientData.filter(
(client: { selected: boolean; status: string }) => client.selected
);
if (this.selectedClients.length === 0 && this.clientData.length > 0) {
this.selectedClients = [this.clientData[0]];
this.clientData[0].selected = true;
}
}
getPartitionsTooltip(client: any): string {
@ -267,12 +358,18 @@ export class PartitionAssistantComponent implements OnInit{
memoryUsage: 0,
sizeBytes: 0,
format: false,
color: '#' + Math.floor(Math.random() * 16777215).toString(16),
color: this.getNextPartitionColor(),
percentage: 0,
removed: false
});
this.updatePartitionPercentages(disk.partitions, disk.totalDiskSize);
this.updateDiskChart(disk);
this.validationSubject.next();
setTimeout(() => {
this.resizeChart();
}, 100);
} else {
this.toastService.error('No hay suficiente espacio libre en el disco para crear una nueva partición.');
}
@ -297,6 +394,8 @@ export class PartitionAssistantComponent implements OnInit{
partition.percentage = (size / disk.totalDiskSize) * 100;
this.updatePartitionPercentages(disk.partitions, disk.totalDiskSize);
this.updateDiskChart(disk);
this.validationSubject.next();
}
}
}
@ -311,6 +410,10 @@ export class PartitionAssistantComponent implements OnInit{
save() {
if (this.selectedClients.length === 0 && this.clientData.length > 0) {
this.updateSelectedClients();
}
if (!this.selectedDisk) {
this.toastService.error('No se ha seleccionado un disco.');
return;
@ -389,6 +492,12 @@ export class PartitionAssistantComponent implements OnInit{
this.updateDiskChart(disk);
this.updatePartitionPercentages(disk.partitions, disk.totalDiskSize);
this.validationSubject.next();
setTimeout(() => {
this.resizeChart();
}, 100);
}
}
@ -401,9 +510,55 @@ export class PartitionAssistantComponent implements OnInit{
}
this.updateDiskChart(disk);
this.updatePartitionPercentages(disk.partitions, disk.totalDiskSize);
this.validationSubject.next();
}
}
validatePartitionSizes(): void {
if (!this.selectedModelClient || !this.selectedDisk) {
return;
}
this.partitionValidationStatus = 'loading';
this.partitionValidationMessage = '';
const partitions = this.selectedDisk.partitions
.filter((partition: Partition) => !partition.removed)
.map((partition: Partition) => ({
diskNumber: this.selectedDisk.diskNumber,
partitionNumber: partition.partitionNumber,
size: partition.size,
partitionCode: partition.partitionCode,
filesystem: partition.filesystem
}));
const payload = {
partitions: partitions
};
const url = `${this.baseUrl}${this.selectedModelClient.uuid}/check-partition-sizes`;
this.http.post(url, payload).subscribe(
(response: any) => {
if (response.res === 1) {
this.partitionValidationStatus = 'success';
this.partitionValidationMessage = 'Las particiones cumplen con los requisitos del disco.';
} else if (response.res === 2) {
this.partitionValidationStatus = 'error';
this.partitionValidationMessage = response.der || 'Las particiones no cumplen con los requisitos del disco.';
} else {
this.partitionValidationStatus = 'error';
this.partitionValidationMessage = 'Respuesta inesperada del servidor.';
}
},
(error) => {
this.partitionValidationStatus = 'error';
this.partitionValidationMessage = error.error?.message || 'Error al validar las particiones.';
}
);
}
calculateUsedSpace(partitions: Partition[]): number {
return partitions
.filter(partition => !partition.removed)
@ -429,19 +584,36 @@ export class PartitionAssistantComponent implements OnInit{
}
updateDiskChart(disk: any) {
console.log('disk', disk);
disk.chartData = this.generateChartData(disk.partitions);
disk.used = this.calculateUsedSpace(disk.partitions);
disk.percentage = (disk.used / disk.totalDiskSize) * 100;
setTimeout(() => {
this.resizeChart();
}, 50);
}
openScheduleModal(): void {
let scope = this.runScriptContext?.type || 'clients';
let selectedClients = null;
if (this.selectedClients.length === 0 && this.clientData.length > 0) {
this.updateSelectedClients();
}
if (this.selectedClients && this.selectedClients.length > 0) {
scope = 'clients';
selectedClients = this.selectedClients;
}
const dialogRef = this.dialog.open(CreateTaskComponent, {
width: '800px',
data: {
scope: this.runScriptContext.type,
organizationalUnit: this.runScriptContext['@id'],
source: 'assistant'
scope: scope,
selectedClients: selectedClients,
organizationalUnit: this.runScriptContext?.['@id'],
source: 'assistant',
runScriptContext: this.runScriptContext
}
});
@ -502,11 +674,16 @@ export class PartitionAssistantComponent implements OnInit{
onDiskSelectionChange() {
if (this.selectedDiskNumber) {
this.scrollToPartitionTable();
this.validationSubject.next();
setTimeout(() => {
this.resizeChart();
}, 150);
}
}
scrollToPartitionTable() {
// Pequeño delay para asegurar que el contenido se haya renderizado
setTimeout(() => {
const diskInfo = document.getElementById('disk-info');
@ -536,4 +713,40 @@ export class PartitionAssistantComponent implements OnInit{
console.error('No se encontró el botón execute-button');
}
}
getPartitionsDataSource(): MatTableDataSource<Partition> {
return new MatTableDataSource<Partition>(this.selectedDisk?.partitions || []);
}
getPartitionIndex(partition: Partition): number {
return this.selectedDisk?.partitions.findIndex((p: Partition) => p.uuid === partition.uuid) || -1;
}
getFreeSpacePercentage(): number {
if (!this.selectedDisk) return 0;
return this.selectedDisk.totalDiskSize > 0 ?
((this.selectedDisk.totalDiskSize - this.selectedDisk.used) / this.selectedDisk.totalDiskSize) * 100 : 0;
}
private getNextPartitionColor(): string {
if (!this.selectedDisk) return this.partitionColors[0];
const usedColors = this.selectedDisk.partitions
.filter((p: Partition) => !p.removed)
.map((p: Partition) => p.color);
for (const color of this.partitionColors) {
if (!usedColors.includes(color)) {
return color;
}
}
return this.partitionColors[Math.floor(Math.random() * this.partitionColors.length)];
}
private getColorForPartition(partitionNumber: number): string {
return this.partitionColors[(partitionNumber - 1) % this.partitionColors.length];
}
}

View File

@ -10,11 +10,11 @@
</h4>
</div>
<div class="button-row">
<button class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || loading" (click)="save()">Ejecutar</button>
<button class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || (commandType === 'new' && !newScript.trim()) || loading" (click)="save()">Ejecutar</button>
</div>
<div class="button-row">
<button color="accent" class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || loading" (click)="openScheduleModal()">
<button color="accent" class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || (commandType === 'new' && !newScript.trim()) || loading" (click)="openScheduleModal()">
Opciones de programación
</button>
</div>

View File

@ -29,7 +29,7 @@ export class RunScriptAssistantComponent implements OnInit{
parameters: any = {};
selectedScript: any = null;
selectedClients: any[] = [];
allSelected: boolean = true;
allSelected: boolean = false;
commandType: string = 'existing';
newScript: string = '';
selection = new SelectionModel(true, []);
@ -56,7 +56,11 @@ export class RunScriptAssistantComponent implements OnInit{
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.selectedClients = this.clientData.filter(
(client: { selected: boolean; status: string }) => client.selected
);
this.allSelected = this.clientData.length > 0 && this.clientData.every((client: { selected: boolean }) => client.selected);
this.loadScripts()
}
@ -117,12 +121,15 @@ export class RunScriptAssistantComponent implements OnInit{
}
updateSelectedClients() {
this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
this.selectedClients = this.clientData.filter(
(client: { selected: boolean; status: string }) => client.selected
);
}
toggleSelectAll() {
this.allSelected = !this.allSelected;
this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
this.updateSelectedClients();
}
getPartitionsTooltip(client: any): string {
@ -198,21 +205,33 @@ export class RunScriptAssistantComponent implements OnInit{
}
openScheduleModal(): void {
let scope = this.runScriptContext.type;
let selectedClients = null;
if ((!this.runScriptContext || this.runScriptContext.type === 'client' || this.selectedClients.length === 1) && this.selectedClients && this.selectedClients.length > 0) {
scope = 'clients';
selectedClients = this.selectedClients;
}
const dialogRef = this.dialog.open(CreateTaskComponent, {
width: '800px',
data: {
scope: this.runScriptContext.type,
organizationalUnit: this.runScriptContext['@id'],
source: 'assistant'
scope: scope,
selectedClients: selectedClients,
organizationalUnit: this.runScriptContext?.['@id'],
source: 'assistant',
runScriptContext: this.runScriptContext
}
});
dialogRef.afterClosed().subscribe(result => {
console.log(result);
if (result) {
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: result['@id'],
commandTask: result.taskId['@id'],
content: this.commandType === 'existing' ? this.scriptContent : this.newScript,
order: 1,
order: result.executionOrder,
type: 'run-script',
}).subscribe({
next: () => {

View File

@ -422,6 +422,10 @@
<mat-icon>article</mat-icon>
<span>Logs en tiempo real</span>
</button>
<button mat-menu-item (click)="openClientLogsModal($event, client)">
<mat-icon>analytics</mat-icon>
<span>Logs de cliente</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
@ -613,6 +617,10 @@
<mat-icon>article</mat-icon>
<span>Logs en tiempo real</span>
</button>
<button mat-menu-item (click)="openClientLogsModal($event, client)">
<mat-icon>analytics</mat-icon>
<span>Logs de cliente</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>

View File

@ -31,6 +31,7 @@ import { ClientTaskLogsComponent } from '../task-logs/client-task-logs/client-ta
import {ChangeParentComponent} from "./shared/change-parent/change-parent.component";
import { AuthService } from '@services/auth.service';
import { ClientPendingTasksComponent } from '../task-logs/client-pending-tasks/client-pending-tasks.component';
import { ClientLogsModalComponent } from './shared/client-logs-modal/client-logs-modal.component';
enum NodeType {
OrganizationalUnit = 'organizational-unit',
@ -995,6 +996,27 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
}
openClientLogsModal(event: MouseEvent, client: Client): void {
event.stopPropagation();
if (!client.mac) {
this.toastr.error('No se puede acceder a los logs: MAC del cliente no disponible', 'Error');
return;
}
const dialogRef = this.dialog.open(ClientLogsModalComponent, {
width: '1400px',
height: '90vh',
data: { client },
disableClose: false,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
// El modal se cerró
});
}
openOUPendingTasks(event: MouseEvent, node: any): void {
event.stopPropagation();
this.loading = true;

View File

@ -0,0 +1,139 @@
.client-logs-modal {
display: flex;
flex-direction: column;
height: 100%;
max-height: 90vh;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e0e0e0;
background-color: #fafafa;
}
.header-actions {
display: flex;
gap: 8px;
}
.grafana-button {
color: #666;
transition: color 0.2s ease;
}
.grafana-button:hover {
color: #2196f3;
}
.modal-header h2 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 1.25rem;
color: #333;
}
.close-button {
color: #666;
}
.close-button:hover {
color: #333;
}
.modal-content {
flex: 1;
padding: 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.client-info {
margin-bottom: 16px;
padding: 12px;
background-color: #f5f5f5;
border-radius: 4px;
border-left: 4px solid #2196f3;
}
.client-info p {
margin: 4px 0;
font-size: 0.9rem;
}
.client-info strong {
color: #333;
}
.iframe-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: #f9f9f9;
border-radius: 4px;
overflow: hidden;
}
.logs-iframe {
border: none;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background-color: white;
}
.modal-actions {
padding: 16px 24px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
background-color: #fafafa;
}
.modal-actions button {
min-width: 80px;
}
/* Responsive design */
@media (max-width: 1200px) {
.logs-iframe {
width: 100% !important;
height: 600px !important;
}
}
@media (max-width: 768px) {
.logs-iframe {
width: 100% !important;
height: 400px !important;
}
.modal-content {
padding: 16px;
}
.modal-header {
padding: 12px 16px;
}
.modal-actions {
padding: 12px 16px;
}
}
.action-button {
border-radius: 8px;
font-weight: 500;
padding: 12px 24px;
transition: all 0.3s ease;
}
.action-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

View File

@ -0,0 +1,37 @@
<div class="client-logs-modal">
<div class="modal-header">
<h2 mat-dialog-title>
<mat-icon>article</mat-icon>
Logs de cliente - {{ data.client.name }}
</h2>
<div class="header-actions">
<button mat-icon-button (click)="openGrafanaInNewTab()" matTooltip="Abrir en Grafana" class="grafana-button">
<mat-icon>open_in_new</mat-icon>
</button>
</div>
</div>
<div class="modal-content">
<div class="client-info">
<p><strong>Cliente:</strong> {{ data.client.name }}</p>
<p><strong>IP:</strong> {{ data.client.ip }}</p>
<p><strong>MAC:</strong> {{ data.client.mac }}</p>
</div>
<div class="iframe-container">
<iframe
[src]="iframeUrl | safe"
width="1200"
height="800"
frameborder="0"
class="logs-iframe">
</iframe>
</div>
</div>
<div class="modal-actions">
<button class="action-button" (click)="closeModal()">
Cerrar
</button>
</div>
</div>

View File

@ -0,0 +1,29 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-client-logs-modal',
templateUrl: './client-logs-modal.component.html',
styleUrls: ['./client-logs-modal.component.css']
})
export class ClientLogsModalComponent {
iframeUrl: string;
grafanaUrl: string;
constructor(
public dialogRef: MatDialogRef<ClientLogsModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: { client: any }
) {
const mac = this.data.client.mac || '';
this.iframeUrl = `https://localhost:3030/d-solo/opengnsys-clients/filebeat-clients?orgId=1&timezone=browser&var-hostname=$__all&var-mac=${mac}&refresh=5s&panelId=1&__feature.dashboardSceneSolo`;
this.grafanaUrl = `https://localhost:3030/d/opengnsys-clients/filebeat-clients?orgId=1&from=now-5m&to=now&timezone=browser&var-hostname=$__all&var-mac=${mac}&refresh=5s&viewPanel=panel-1`;
}
closeModal(): void {
this.dialogRef.close();
}
openGrafanaInNewTab(): void {
window.open(this.grafanaUrl, '_blank');
}
}

View File

@ -0,0 +1,67 @@
.dialog-content {
min-width: 400px;
max-width: 600px;
}
.commit-info {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.commit-info p {
margin: 5px 0;
font-size: 14px;
}
.branch-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-field {
width: 100%;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding: 0 24px 24px 24px;
}
.ordinary-button {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.ordinary-button:hover {
background-color: #e0e0e0;
}
.submit-button {
background-color: #3f51b5;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.submit-button:hover:not(:disabled) {
background-color: #303f9f;
}
.submit-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}

View File

@ -0,0 +1,31 @@
<app-loading [isLoading]="loading"></app-loading>
<h2 mat-dialog-title>Crear Rama desde Commit</h2>
<mat-dialog-content class="dialog-content">
<div class="commit-info">
<p><strong>Commit ID:</strong> {{ data.commit?.hexsha }}</p>
<p><strong>Mensaje:</strong> {{ data.commit?.message }}</p>
</div>
<form [formGroup]="branchForm" (ngSubmit)="createBranch()" class="branch-form">
<mat-form-field appearance="fill" class="form-field">
<mat-label>Nombre de la rama</mat-label>
<input matInput formControlName="name" placeholder="ej: feature-nueva-funcionalidad" required>
<mat-error *ngIf="branchForm.get('name')?.hasError('required')">
El nombre de la rama es obligatorio
</mat-error>
<mat-error *ngIf="branchForm.get('name')?.hasError('pattern')">
El nombre de la rama solo puede contener letras, números, puntos, guiones y guiones bajos
</mat-error>
<mat-hint>Ejemplo: feature-nueva-funcionalidad, hotfix-bug-123, release-v2.0</mat-hint>
</mat-form-field>
</form>
</mat-dialog-content>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="createBranch()" [disabled]="branchForm.invalid || loading">
Crear Rama
</button>
</div>

View File

@ -0,0 +1,61 @@
import { Component, Inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr';
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-create-branch-modal',
templateUrl: './create-branch-modal.component.html',
styleUrl: './create-branch-modal.component.css'
})
export class CreateBranchModalComponent {
branchForm: FormGroup;
loading: boolean = false;
baseUrl: string;
constructor(
private fb: FormBuilder,
private http: HttpClient,
public dialogRef: MatDialogRef<CreateBranchModalComponent>,
private toastService: ToastrService,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: { commit: any, repositoryName: string, repositoryUuid: string }
) {
this.baseUrl = this.configService.apiUrl;
this.branchForm = this.fb.group({
name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9._-]+$/)]],
});
}
createBranch(): void {
if (this.branchForm.valid) {
this.loading = true;
const payload = {
commit: this.data.commit?.hexsha || 'master',
name: this.branchForm.value.name,
repository: this.data.repositoryName
};
const url = `${this.baseUrl}/image-repositories/server/git/${this.data.repositoryUuid}/create-branch`;
this.http.post(url, payload).subscribe({
next: (response) => {
this.toastService.success('Rama creada correctamente');
this.dialogRef.close(response);
},
error: (error) => {
this.toastService.error(error.error?.message || 'Error al crear la rama');
this.loading = false;
}
});
} else {
this.toastService.error('Por favor, complete todos los campos requeridos');
}
}
close(): void {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,78 @@
.dialog-content {
min-width: 400px;
max-width: 500px;
padding: 0 24px 24px 24px;
}
h2[mat-dialog-title] {
padding: 24px 24px 0 24px;
margin: 0;
}
.commit-info {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border-left: 4px solid #3f51b5;
}
.commit-info p {
margin: 5px 0;
font-size: 14px;
}
.commit-info strong {
color: #3f51b5;
}
.tag-form {
display: flex;
flex-direction: column;
gap: 15px;
}
.form-field {
width: 100%;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding: 0 24px 24px 24px;
}
.ordinary-button {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.ordinary-button:hover {
background-color: #e0e0e0;
}
.submit-button {
background-color: #3f51b5;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.submit-button:hover:not(:disabled) {
background-color: #303f9f;
}
.submit-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}

View File

@ -0,0 +1,40 @@
<app-loading [isLoading]="loading"></app-loading>
<h2 mat-dialog-title>Crear Tag para Commit</h2>
<mat-dialog-content class="dialog-content">
<div class="commit-info">
<p><strong>Commit ID:</strong> {{ data.commit.hexsha }}</p>
<p><strong>Mensaje:</strong> {{ data.commit.message }}</p>
</div>
<form [formGroup]="tagForm" (ngSubmit)="createTag()" class="tag-form">
<mat-form-field appearance="fill" class="form-field">
<mat-label>Nombre del tag</mat-label>
<input matInput formControlName="name" placeholder="ej: v1.0.0" required>
<mat-error *ngIf="tagForm.get('name')?.hasError('required')">
El nombre del tag es obligatorio
</mat-error>
<mat-error *ngIf="tagForm.get('name')?.hasError('pattern')">
El nombre del tag solo puede contener letras, números, puntos, guiones y guiones bajos
</mat-error>
<mat-hint>Ejemplo: v1.0.0, release-2024-01, hotfix-bug-123</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="form-field">
<mat-label>Mensaje del tag</mat-label>
<textarea matInput formControlName="message" placeholder="Descripción del tag" required rows="3"></textarea>
<mat-error *ngIf="tagForm.get('message')?.hasError('required')">
El mensaje del tag es obligatorio
</mat-error>
<mat-hint>Descripción del tag (ej: "Release estable de la versión 1.0")</mat-hint>
</mat-form-field>
</form>
</mat-dialog-content>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="createTag()" [disabled]="tagForm.invalid || loading">
Crear Tag
</button>
</div>

View File

@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateTagModalComponent } from './create-tag-modal.component';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { ToastrService } from 'ngx-toastr';
import { ToastrModule } from 'ngx-toastr';
import { ConfigService } from '@services/config.service';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { LoadingComponent } from '../../../../shared/loading/loading.component';
describe('CreateTagModalComponent', () => {
let component: CreateTagModalComponent;
let fixture: ComponentFixture<CreateTagModalComponent>;
beforeEach(async () => {
const mockToastrService = {
success: jasmine.createSpy('success'),
error: jasmine.createSpy('error'),
warning: jasmine.createSpy('warning'),
info: jasmine.createSpy('info')
};
const mockConfigService = {
apiUrl: 'http://mock-api-url'
};
await TestBed.configureTestingModule({
declarations: [CreateTagModalComponent, LoadingComponent],
imports: [
MatDialogModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
NoopAnimationsModule,
TranslateModule.forRoot(),
ToastrModule.forRoot()
],
providers: [
FormBuilder,
HttpClient,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ToastrService, useValue: mockToastrService },
{ provide: ConfigService, useValue: mockConfigService },
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {
commit: {
hexsha: 'test-commit-id',
message: 'Test commit message'
},
repositoryName: 'test-repo'
}
}
]
})
.compileComponents();
fixture = TestBed.createComponent(CreateTagModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,63 @@
import { Component, Inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr';
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-create-tag-modal',
templateUrl: './create-tag-modal.component.html',
styleUrl: './create-tag-modal.component.css'
})
export class CreateTagModalComponent {
tagForm: FormGroup;
loading: boolean = false;
baseUrl: string;
constructor(
private fb: FormBuilder,
private http: HttpClient,
public dialogRef: MatDialogRef<CreateTagModalComponent>,
private toastService: ToastrService,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: { commit: any, repositoryName: string, repositoryUuid: string }
) {
this.baseUrl = this.configService.apiUrl;
this.tagForm = this.fb.group({
name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9._-]+$/)]],
message: ['', Validators.required]
});
}
createTag(): void {
if (this.tagForm.valid) {
this.loading = true;
const payload = {
commit: this.data.commit.hexsha,
name: this.tagForm.value.name,
message: this.tagForm.value.message,
repository: this.data.repositoryName
};
const url = `${this.baseUrl}/image-repositories/server/git/${this.data.repositoryUuid}/create-tag`;
this.http.post(url, payload).subscribe({
next: (response) => {
this.toastService.success('Tag creado correctamente');
this.dialogRef.close(response);
},
error: (error) => {
this.toastService.error(error.error?.message || 'Error al crear el tag');
this.loading = false;
}
});
} else {
this.toastService.error('Por favor, complete todos los campos requeridos');
}
}
close(): void {
this.dialogRef.close();
}
}

View File

@ -95,6 +95,14 @@
<button mat-icon-button color="primary" (click)="toggleAction(commit, 'view-details')" matTooltip="Ver detalles">
<mat-icon>info</mat-icon>
</button>
<button mat-icon-button color="accent" (click)="toggleAction(commit, 'create-tag')" matTooltip="Crear tag">
<mat-icon>local_offer</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="toggleAction(commit, 'create-branch')" matTooltip="Crear rama">
<mat-icon>account_tree</mat-icon>
</button>
</div>
</td>
</ng-container>

View File

@ -1,4 +1,4 @@
import {Component, Inject, Input, isDevMode, OnInit} from '@angular/core';
import {Component, Inject, isDevMode, OnInit} from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {DatePipe} from "@angular/common";
import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from "@angular/material/dialog";
@ -7,13 +7,9 @@ import {ToastrService} from "ngx-toastr";
import {JoyrideService} from "ngx-joyride";
import {ConfigService} from "@services/config.service";
import {Router} from "@angular/router";
import {Observable} from "rxjs";
import {ServerInfoDialogComponent} from "../../ogdhcp/server-info-dialog/server-info-dialog.component";
import {ImportImageComponent} from "../import-image/import-image.component";
import {DeleteModalComponent} from "../../../shared/delete_modal/delete-modal/delete-modal.component";
import {ExportImageComponent} from "../../images/export-image/export-image.component";
import {BackupImageComponent} from "../backup-image/backup-image.component";
import {EditImageComponent} from "../edit-image/edit-image.component";
import {CreateTagModalComponent} from "./create-tag-modal/create-tag-modal.component";
import {CreateBranchModalComponent} from "./create-branch-modal/create-branch-modal.component";
@Component({
selector: 'app-show-git-commits',
@ -212,6 +208,12 @@ export class ShowGitCommitsComponent implements OnInit{
this.toastService.success('Commit ID copiado al portapapeles');
});
break;
case 'create-tag':
this.openCreateTagDialog(commit);
break;
case 'create-branch':
this.openCreateBranchDialog(commit);
break;
default:
console.error('Acción no soportada:', action);
break;
@ -253,6 +255,43 @@ export class ShowGitCommitsComponent implements OnInit{
});
}
openCreateTagDialog(commit: any) {
const dialogRef = this.dialog.open(CreateTagModalComponent, {
width: '500px',
data: {
commit: commit,
repositoryName: this.selectedRepository,
repositoryUuid: this.data.repositoryUuid
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
// Recargar los datos para mostrar el nuevo tag
this.loadData();
}
});
}
openCreateBranchDialog(commit: any) {
const dialogRef = this.dialog.open(CreateBranchModalComponent, {
width: '500px',
data: {
commit: commit,
repositoryName: this.selectedRepository,
repositoryUuid: this.data.repositoryUuid
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
// Recargar los datos para mostrar la nueva rama
this.loadBranches();
this.loadData();
}
});
}
goToPage(commit: any) {
window.open(`http://localhost:3100/oggit/${this.selectedRepository}/commit/${commit.hexsha}`, '_blank');
}

View File

@ -42,8 +42,8 @@ export class HeaderComponent implements OnInit {
showGlobalStatus() {
this.dialog.open(GlobalStatusComponent, {
width: '65vw',
height: '80vh',
width: '80vw',
height: '85vh',
})
}

View File

@ -15,7 +15,7 @@ export class QueueConfirmationModalComponent {
) {}
onNoClick(): void {
this.dialogRef.close(false);
this.dialogRef.close();
}
onYesClick(): void {