Merge pull request 'develop' (#28) from develop into main
oggui-debian-package/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag There was a failure building this commit Details

Reviewed-on: #28
main 0.15.0
Manuel Aranda Rosales 2025-06-26 16:23:03 +02:00
commit e024c7a246
69 changed files with 9770 additions and 1914 deletions

View File

@ -1,4 +1,17 @@
# Changelog
## [0.15.0] - 2025-06-26
### Added
- Se ha añadido integracion con OgGit. Ahora se pueden crear y desplegar imagenes.
- Ahora se pueden gestionar cola de acciones y vaciarlas tanto a nivel de aula como de cliente.
- Nuevos componentes que ayudan a la mejora general de la UX
- Mejora en el comportamiento de los asistentes.
- Se puede cancelar tareas desde la parte de Trazas
### Changed
- Se ha cambiado la vista de las imagenes de Git
---
## [0.14.1] - 2025-06-09
### Fixed
- Se han corregido los errores en produccion que hacia que no salieran mensajes desde la API correctamente.

View File

@ -91,6 +91,7 @@ import { MatSliderModule } from '@angular/material/slider';
import { ImagesComponent } from './components/images/images.component';
import { CreateImageComponent } from './components/images/create-image/create-image.component';
import { CreateClientImageComponent } from './components/groups/components/client-main-view/create-image/create-image.component';
import { CreateRepositoryModalComponent } from './components/groups/components/client-main-view/create-image/create-repository-modal/create-repository-modal.component';
import { PartitionAssistantComponent } from './components/groups/components/client-main-view/partition-assistant/partition-assistant.component';
import { SoftwareComponent } from './components/software/software.component';
import { CreateSoftwareComponent } from './components/software/create-software/create-software.component';
@ -139,7 +140,7 @@ import {
SaveScriptComponent
} from "./components/groups/components/client-main-view/run-script-assistant/save-script/save-script.component";
import { EditImageComponent } from './components/repositories/edit-image/edit-image.component';
import { ShowGitImagesComponent } from './components/repositories/show-git-images/show-git-images.component';
import { ShowGitCommitsComponent } from './components/repositories/show-git-images/show-git-images.component';
import { RenameImageComponent } from './components/repositories/rename-image/rename-image.component';
import { ClientDetailsComponent } from './components/groups/shared/client-details/client-details.component';
import { PartitionTypeOrganizatorComponent } from './components/groups/shared/partition-type-organizator/partition-type-organizator.component';
@ -154,6 +155,10 @@ import { BootSoPartitionComponent } from './components/commands/main-commands/ex
import { RemoveCacheImageComponent } from './components/commands/main-commands/execute-command/remove-cache-image/remove-cache-image.component';
import { ChangeParentComponent } from './components/groups/shared/change-parent/change-parent.component';
import { SoftwareProfilePartitionComponent } from './components/commands/main-commands/execute-command/software-profile-partition/software-profile-partition.component';
import { ClientPendingTasksComponent } from './components/task-logs/client-pending-tasks/client-pending-tasks.component';
import { QueueConfirmationModalComponent } from './shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { ModalOverlayComponent } from './shared/modal-overlay/modal-overlay.component';
import { ScrollToTopComponent } from './shared/scroll-to-top/scroll-to-top.component';
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './locale/', '.json');
@ -182,6 +187,7 @@ registerLocaleData(localeEs, 'es-ES');
GroupsComponent,
ManageClientComponent,
DeleteModalComponent,
QueueConfirmationModalComponent,
ClassroomViewComponent,
ClientViewComponent,
ShowOrganizationalUnitComponent,
@ -204,6 +210,8 @@ registerLocaleData(localeEs, 'es-ES');
CalendarComponent,
CreateCalendarComponent,
CreateClientImageComponent,
CreateRepositoryModalComponent,
PartitionAssistantComponent,
CreateCalendarRuleComponent,
CommandsGroupsComponent,
CommandsTaskComponent,
@ -216,7 +224,6 @@ registerLocaleData(localeEs, 'es-ES');
StatusComponent,
ImagesComponent,
CreateImageComponent,
PartitionAssistantComponent,
SoftwareComponent,
CreateSoftwareComponent,
SoftwareProfileComponent,
@ -230,7 +237,6 @@ registerLocaleData(localeEs, 'es-ES');
ExecuteCommandOuComponent,
DeployImageComponent,
MainRepositoryViewComponent,
ExecuteCommandOuComponent,
EnvVarsComponent,
MenusComponent,
CreateMenuComponent,
@ -251,7 +257,7 @@ registerLocaleData(localeEs, 'es-ES');
RunScriptAssistantComponent,
SaveScriptComponent,
EditImageComponent,
ShowGitImagesComponent,
ShowGitCommitsComponent,
RenameImageComponent,
ClientDetailsComponent,
PartitionTypeOrganizatorComponent,
@ -265,7 +271,10 @@ registerLocaleData(localeEs, 'es-ES');
BootSoPartitionComponent,
RemoveCacheImageComponent,
ChangeParentComponent,
SoftwareProfilePartitionComponent
SoftwareProfilePartitionComponent,
ClientPendingTasksComponent,
ModalOverlayComponent,
ScrollToTopComponent
],
bootstrap: [AppComponent],
imports: [BrowserModule,

View File

@ -22,8 +22,8 @@
<div class="clients-grid">
<div *ngFor="let client of data.clients" class="client-item">
<div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}" >
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" >
<img
[src]="'assets/images/computer_' + client.status + '.svg'"

View File

@ -66,19 +66,11 @@ export class BootSoPartitionComponent implements OnInit {
this.baseUrl = this.configService.apiUrl;
this.clientId = this.data.clients?.length ? this.data.clients[0]['@id'] : null;
this.data.clients.forEach((client: { selected: boolean; status: string }) => {
if (client.status === 'og-live') {
client.selected = true;
}
});
this.data.clients.forEach((client: { selected: boolean; status: string }) => client.selected = true);
this.selectedClients = this.data.clients.filter(
(client: { status: string }) => client.status === 'og-live'
);
this.selectedClients = this.data.clients.filter((client: { selected: boolean }) => client.selected);
this.selectedModelClient = this.data.clients.find(
(client: { status: string }) => client.status === 'og-live'
) || null;
this.selectedModelClient = this.data.clients.find((client: { selected: boolean }) => client.selected) || null;
if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient);

View File

@ -1,44 +1,557 @@
/* ===== HEADER DE BIENVENIDA ===== */
.welcome-header {
background: #3f51b5;
color: white;
padding: 2rem 2rem 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
position: relative;
overflow: hidden;
}
.welcome-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="10" cy="60" r="0.5" fill="white" opacity="0.1"/><circle cx="90" cy="40" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.2;
}
.welcome-content {
display: flex;
align-items: center;
gap: 1.5rem;
position: relative;
z-index: 1;
}
.welcome-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.welcome-icon mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: white;
}
.welcome-text {
flex: 1;
}
.welcome-title {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.welcome-subtitle {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
font-weight: 500;
opacity: 0.95;
}
.welcome-description {
margin: 0;
font-size: 0.9rem;
opacity: 0.8;
}
.welcome-actions {
display: flex;
gap: 0.5rem;
position: relative;
z-index: 1;
}
.help-button,
.refresh-button {
background: rgba(255, 255, 255, 0.2) !important;
color: white !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.help-button:hover,
.refresh-button:hover {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* ===== CONTENIDO PRINCIPAL ===== */
mat-dialog-content {
height: calc(100% - 64px);
overflow: auto;
padding-top: 0.5em !important;
height: calc(100% - 200px);
overflow: auto;
padding: 0 !important;
background: #f8f9fa;
}
.content-container {
min-height: 100%;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
.main-content {
padding: 2rem;
}
/* ===== SPINNER DE CARGA ===== */
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 100px;
height: 100px;
width: 60px !important;
height: 60px !important;
margin-bottom: 1rem;
}
.loading-text {
margin: 0;
color: #666;
font-weight: 500;
}
/* ===== RESUMEN DEL SISTEMA ===== */
.system-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.overview-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 1px solid rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.overview-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
opacity: 0;
transition: opacity 0.3s ease;
}
.overview-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.overview-card:hover::before {
opacity: 1;
}
.overview-icon {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.overview-icon mat-icon {
color: white;
font-size: 24px;
width: 24px;
height: 24px;
}
.overview-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
}
.overview-content p {
margin: 0;
font-size: 0.9rem;
color: #6c757d;
}
/* ===== BADGES DE ESTADO ===== */
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.online {
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
}
.status-badge.offline {
background: linear-gradient(135deg, #dc3545, #c82333);
color: white;
}
.status-badge.info {
background: linear-gradient(135deg, #17a2b8, #138496);
color: white;
}
/* ===== TABS PRINCIPALES ===== */
.main-tabs {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
::ng-deep .main-tabs .mat-tab-header {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
::ng-deep .main-tabs .mat-tab-label {
font-weight: 600;
color: #6c757d;
transition: all 0.3s ease;
}
::ng-deep .main-tabs .mat-tab-label-active {
color: #667eea;
}
::ng-deep .main-tabs .mat-ink-bar {
background: linear-gradient(90deg, #667eea, #764ba2);
height: 3px;
}
.tab-content {
padding: 2rem;
}
/* ===== CONTENEDOR DE ERRORES ===== */
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
padding: 2rem;
}
.error-card {
margin: 20px auto;
max-width: 600px;
text-align: center;
background-color: rgb(243, 243, 243);
color: rgb(48, 48, 48);
background: white;
border-radius: 16px;
padding: 3rem 2rem;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
max-width: 400px;
}
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: #dc3545;
margin-bottom: 1rem;
}
.error-card h3 {
margin: 0 0 1rem 0;
color: #2c3e50;
font-weight: 600;
}
.error-card p {
margin-top: 0;
margin: 0 0 2rem 0;
color: #6c757d;
line-height: 1.5;
}
/* ===== REPOSITORIOS ===== */
.repositories-container {
padding: 1rem;
}
.repositories-selector-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.repository-selector {
display: flex;
justify-content: center;
padding: 1rem 0;
}
.repository-content {
padding: 1rem;
}
.repository-select-field {
min-width: 300px;
max-width: 500px;
width: 100%;
}
.selected-repository-content {
animation: fadeInUp 0.6s ease-out;
}
.no-repository-selected {
text-align: center;
padding: 3rem 2rem;
color: #6c757d;
}
.no-repository-selected .no-data-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-repository-selected h3 {
margin: 0 0 1rem 0;
color: #2c3e50;
font-weight: 600;
}
.no-repository-selected p {
margin: 0;
font-size: 1rem;
line-height: 1.5;
}
/* ===== FOOTER CON ACCIONES ===== */
.action-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background: white;
border-top: 1px solid #e9ecef;
border-radius: 0 0 12px 12px;
}
.action-info {
flex: 1;
}
.last-update {
margin: 0;
font-size: 0.85rem;
color: #6c757d;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.secondary-button {
color: #6c757d !important;
border: 1px solid #dee2e6 !important;
transition: all 0.3s ease;
}
.secondary-button:hover {
background: #f8f9fa !important;
border-color: #667eea !important;
color: #667eea !important;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.welcome-header {
padding: 1.5rem 1rem 1rem 1rem;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.welcome-content {
flex-direction: column;
gap: 1rem;
}
.welcome-title {
font-size: 1.5rem;
}
.welcome-subtitle {
font-size: 1rem;
}
.main-content {
padding: 1rem;
}
.system-overview {
grid-template-columns: 1fr;
gap: 1rem;
}
.overview-card {
padding: 1rem;
}
.action-container {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.action-buttons {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.welcome-header {
padding: 1rem;
}
.welcome-icon {
width: 50px;
height: 50px;
}
.welcome-icon mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
.welcome-title {
font-size: 1.25rem;
}
.overview-card {
flex-direction: column;
text-align: center;
}
.tab-content {
padding: 1rem;
}
.repository-select-field {
min-width: 250px;
max-width: 100%;
}
}
/* ===== ANIMACIONES ===== */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.system-overview,
.main-tabs {
animation: fadeInUp 0.6s ease-out;
}
.overview-card {
animation: fadeInUp 0.6s ease-out;
}
.overview-card:nth-child(1) { animation-delay: 0.1s; }
.overview-card:nth-child(2) { animation-delay: 0.2s; }
.overview-card:nth-child(3) { animation-delay: 0.3s; }
/* ===== ESTILOS GLOBALES DEL DIALOG ===== */
::ng-deep .mat-dialog-container {
border-radius: 16px !important;
overflow: hidden !important;
padding: 0 !important;
}
::ng-deep .mat-dialog-content {
margin: 0 !important;
padding: 0 !important;
}
/* ===== LOADING INDIVIDUAL POR TAB ===== */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
padding: 2rem;
}
.loading-container .loading-content {
text-align: center;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.loading-container .loading-spinner {
width: 60px !important;
height: 60px !important;
margin-bottom: 1rem;
}
.loading-container .loading-text {
margin: 0;
color: #666;
font-weight: 500;
}

View File

@ -1,68 +1,209 @@
<header>
<h1 mat-dialog-title>{{'GlobalStatus' | translate}}</h1>
</header>
<mat-dialog-content [ngClass]="{'loading': loading}">
<div class="spinner-container" *ngIf="loading">
<mat-spinner class="loading-spinner"></mat-spinner>
<!-- Header con bienvenida -->
<div class="welcome-header">
<div class="welcome-content">
<div class="welcome-icon">
<mat-icon>dashboard</mat-icon>
</div>
<div class="welcome-text">
<h1 class="welcome-title">{{ 'GlobalStatus' | translate }}</h1>
<p class="welcome-subtitle">Bienvenido a la consola de administración de OpenGnsys</p>
<p class="welcome-description">Estado general de todos los componentes de la plataforma</p>
</div>
</div>
<mat-tab-group (selectedTabChange)="onTabChange($event)">
<mat-tab label="OgBoot">
<div *ngIf="!loading && !errorOgBoot" class="content-container">
<app-status-tab [loading]="loading" [diskUsage]="ogBootDiskUsage" [servicesStatus]="ogBootServicesStatus"
[installedOgLives]="installedOgLives" [diskUsageChartData]="ogBootDiskUsageChartData" [view]="view"
[colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="isDhcp"
[isRepository]="false">
</app-status-tab>
</div>
<mat-card *ngIf="!loading && errorOgBoot" class="error-card">
<mat-card-content>
<p>{{ 'errorLoadingData' | translate }}</p>
</mat-card-content>
</mat-card>
</mat-tab>
<div class="welcome-actions">
<button mat-icon-button class="refresh-button" (click)="refreshAll()" matTooltip="Actualizar datos">
<mat-icon>refresh</mat-icon>
</button>
</div>
</div>
<mat-tab label="Dhcp">
<div *ngIf="!loading && !errorDhcp" class="content-container">
<app-status-tab [loading]="loading" [diskUsage]="dhcpDiskUsage" [servicesStatus]="dhcpServicesStatus"
[subnets]="subnets" [diskUsageChartData]="dhcpDiskUsageChartData" [view]="view" [colorScheme]="colorScheme"
[isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="isDhcp" [isRepository]="false">
</app-status-tab>
</div>
<mat-card *ngIf="!loading && errorDhcp" class="error-card">
<mat-card-content>
<p>{{ 'errorLoadingData' | translate }}</p>
</mat-card-content>
</mat-card>
</mat-tab>
<!-- 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>
<p class="loading-text">Cargando estado del sistema...</p>
</div>
</div>
<mat-tab label="{{ 'repositoryLabel' | translate }}">
<mat-tab-group>
<mat-tab *ngFor="let repository of repositories" [label]="repository.name">
<div *ngIf="!loading && !errorRepositories[repository.uuid] && repositoryStatuses[repository.uuid]">
<app-status-tab [loading]="loading" [diskUsage]="repositoryStatuses[repository.uuid].disk"
[servicesStatus]="repositoryStatuses[repository.uuid].services"
[processesStatus]="repositoryStatuses[repository.uuid].processes"
[ramUsage]="repositoryStatuses[repository.uuid].ram" [cpuUsage]="repositoryStatuses[repository.uuid].cpu"
[diskUsageChartData]="[
{ name: 'Usado', value: repositoryStatuses[repository.uuid].disk.used },
{ name: 'Disponible', value: repositoryStatuses[repository.uuid].disk.available }
]" [ramUsageChartData]="[
{ name: 'Usado', value: repositoryStatuses[repository.uuid].ram.used },
{ name: 'Disponible', value: repositoryStatuses[repository.uuid].ram.available }
]" [view]="view" [colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels"
[isDhcp]="false" [isRepository]="true">
</app-status-tab>
<!-- 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-icon">
<mat-icon>storage</mat-icon>
</div>
<div class="overview-content">
<h3>OgBoot Server</h3>
<p *ngIf="!errorOgBoot">Estado: <span class="status-badge online">Operativo</span></p>
<p *ngIf="errorOgBoot">Estado: <span class="status-badge offline">Error</span></p>
</div>
</div>
<div class="overview-card">
<div class="overview-icon">
<mat-icon>router</mat-icon>
</div>
<div class="overview-content">
<h3>DHCP Server</h3>
<p *ngIf="!errorDhcp">Estado: <span class="status-badge online">Operativo</span></p>
<p *ngIf="errorDhcp">Estado: <span class="status-badge offline">Error</span></p>
</div>
</div>
<div class="overview-card">
<div class="overview-icon">
<mat-icon>cloud</mat-icon>
</div>
<div class="overview-content">
<h3>Repositorios</h3>
<p>Total: <span class="status-badge info">{{ repositories.length }}</span></p>
</div>
</div>
</div>
<!-- Tabs principales -->
<mat-tab-group (selectedTabChange)="onTabChange($event)" class="main-tabs">
<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>
<div *ngIf="loadingOgBoot" class="loading-container">
<div class="loading-content">
<mat-spinner class="loading-spinner"></mat-spinner>
<p class="loading-text">Cargando estado de OgBoot...</p>
</div>
<mat-card *ngIf="!loading && errorRepositories[repository.uuid]" class="error-card">
<mat-card-content>
<p>{{ 'errorLoadingData' | translate }}</p>
</mat-card-content>
</mat-card>
</mat-tab>
</mat-tab-group>
</mat-tab>
</mat-tab-group>
</div>
<div *ngIf="errorOgBoot" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
<h3>Error de conexión</h3>
<p>No se pudo conectar con el servidor OgBoot</p>
<button mat-raised-button color="primary" (click)="loadOgBootStatus()">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
</div>
</mat-tab>
<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]="isDhcp" [isRepository]="false">
</app-status-tab>
</div>
<div *ngIf="loadingDhcp" class="loading-container">
<div class="loading-content">
<mat-spinner class="loading-spinner"></mat-spinner>
<p class="loading-text">Cargando estado de DHCP...</p>
</div>
</div>
<div *ngIf="errorDhcp" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
<h3>Error de conexión</h3>
<p>No se pudo conectar con el servidor DHCP</p>
<button mat-raised-button color="primary" (click)="loadDhcpStatus()">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
</div>
</mat-tab>
<mat-tab label="{{ 'repositoryLabel' | translate }}">
<div class="repositories-container">
<div *ngIf="repositories.length === 0" class="no-repositories">
<mat-icon class="no-data-icon">cloud_off</mat-icon>
<h3>No hay repositorios disponibles</h3>
<p>No se encontraron repositorios configurados en el sistema.</p>
</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>
<mat-select [(ngModel)]="selectedRepositoryUuid" (selectionChange)="onRepositoryChange($event.value)">
<mat-option *ngFor="let repository of repositories" [value]="repository.uuid">
{{ repository.name }}
</mat-option>
</mat-select>
<mat-icon matSuffix>storage</mat-icon>
</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>
<div class="repository-content">
<app-status-tab [loading]="loading" [diskUsage]="repositoryStatuses[selectedRepositoryUuid].disk"
[servicesStatus]="repositoryStatuses[selectedRepositoryUuid].services"
[processesStatus]="repositoryStatuses[selectedRepositoryUuid].processes"
[ramUsage]="repositoryStatuses[selectedRepositoryUuid].ram" [cpuUsage]="repositoryStatuses[selectedRepositoryUuid].cpu"
[diskUsageChartData]="[
{ name: 'Usado', value: repositoryStatuses[selectedRepositoryUuid].disk.used },
{ name: 'Disponible', value: repositoryStatuses[selectedRepositoryUuid].disk.available }
]" [ramUsageChartData]="[
{ name: 'Usado', value: repositoryStatuses[selectedRepositoryUuid].ram.used },
{ name: 'Disponible', value: repositoryStatuses[selectedRepositoryUuid].ram.available }
]" [view]="view" [colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels"
[isDhcp]="false" [isRepository]="true">
</app-status-tab>
</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 del selector para ver su información detallada.</p>
</div>
<!-- Error del repositorio seleccionado -->
<div *ngIf="selectedRepositoryUuid && errorRepositories[selectedRepositoryUuid]" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
<h3>Error de conexión</h3>
<p>No se pudo conectar con el repositorio {{ getSelectedRepositoryName() }}</p>
<button mat-raised-button color="primary" (click)="retryRepositoryStatus(selectedRepositoryUuid)">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
</mat-dialog-content>
<!-- Footer con acciones -->
<mat-dialog-actions class="action-container">
<button class="ordinary-button" [mat-dialog-close]="true">{{ 'closeButton' | translate }}</button>
<div class="action-info">
<p class="last-update">Última actualización: {{ lastUpdateTime }}</p>
</div>
<div class="action-buttons">
<button mat-button class="secondary-button" (click)="refreshAll()">
<mat-icon>refresh</mat-icon>
Actualizar
</button>
<button mat-raised-button color="primary" [mat-dialog-close]="true">
{{ 'closeButton' | translate }}
</button>
</div>
</mat-dialog-actions>

View File

@ -12,6 +12,9 @@ import {ToastrService} from "ngx-toastr";
export class GlobalStatusComponent implements OnInit {
baseUrl: string;
loading: boolean = false;
loadingOgBoot: boolean = false;
loadingDhcp: boolean = false;
loadingRepositories: boolean = false;
errorOgBoot: boolean = false;
errorDhcp: boolean = false;
errorRepositories: { [key: string]: boolean } = {};
@ -26,6 +29,8 @@ export class GlobalStatusComponent implements OnInit {
repositoriesUrl: string;
repositories: any[] = [];
repositoryStatuses: { [key: string]: any } = {};
lastUpdateTime: string = '';
selectedRepositoryUuid: string = '';
ogBootApiUrl: string;
ogBootDiskUsage: any = {};
@ -48,13 +53,21 @@ export class GlobalStatusComponent implements OnInit {
this.ogBootApiUrl = `${this.baseUrl}/og-boot/status`;
this.dhcpApiUrl = `${this.baseUrl}/og-dhcp/status`;
this.repositoriesUrl = `${this.baseUrl}/image-repositories`;
this.ogBootDiskUsageChartData = [];
this.dhcpDiskUsageChartData = [];
}
ngOnInit(): void {
this.updateLastUpdateTime();
this.loadOgBootStatus();
this.syncSubnets()
this.syncTemplates()
this.syncOgLives()
this.loadDhcpStatus();
this.loadRepositories(false);
this.syncSubnets();
this.syncTemplates();
this.syncOgLives();
}
syncSubnets() {
@ -106,11 +119,26 @@ export class GlobalStatusComponent implements OnInit {
[key: string]: any;
loadStatus(apiUrl: string, diskUsage: any, servicesStatus: any, diskUsageChartData: any[], installedOgLives: any[], isDhcp: boolean, errorState: string): void {
this.loading = true;
loadStatus(apiUrl: string, diskUsage: any, servicesStatus: any, diskUsageChartData: any[], installedOgLives: any[], isDhcp: boolean, errorState: string, showLoading: boolean = true): void {
if (isDhcp) {
this.loadingDhcp = true;
} else {
this.loadingOgBoot = true;
}
if (showLoading) {
this.loading = true;
}
this[errorState] = false;
const timeoutId = setTimeout(() => {
this.loading = false;
if (showLoading) {
this.loading = false;
}
if (isDhcp) {
this.loadingDhcp = false;
} else {
this.loadingOgBoot = false;
}
this[errorState] = true;
}, 3500);
this.http.get<any>(apiUrl).subscribe({
@ -140,23 +168,41 @@ export class GlobalStatusComponent implements OnInit {
{ name: 'Disponible', value: parseFloat(diskUsage.available) }
);
this.loading = false;
if (showLoading) {
this.loading = false;
}
if (isDhcp) {
this.loadingDhcp = false;
} else {
this.loadingOgBoot = false;
}
clearTimeout(timeoutId);
},
error: error => {
this.toastService.error(error.error['hydra:description'] || 'Error al cargar el estado de ogBoot');
this.loading = false;
if (showLoading) {
this.loading = false;
}
if (isDhcp) {
this.loadingDhcp = false;
} else {
this.loadingOgBoot = false;
}
this[errorState] = true;
clearTimeout(timeoutId);
}
});
}
loadRepositories(): void {
this.loading = true;
loadRepositories(showLoading: boolean = true): void {
if (showLoading) {
this.loading = true;
}
this.errorRepositories = {};
const timeoutId = setTimeout(() => {
this.loading = false;
if (showLoading) {
this.loading = false;
}
this.repositories.forEach(repository => {
if (!(repository.uuid in this.errorRepositories)) {
this.errorRepositories[repository.uuid] = true;
@ -176,14 +222,15 @@ export class GlobalStatusComponent implements OnInit {
this.errorRepositories[repository.uuid] = errorOccurred;
if (remainingRepositories === 0) {
this.loading = false;
if (showLoading) {
this.loading = false;
}
clearTimeout(timeoutId);
}
});
});
},
error => {
console.error('Error fetching repositories', error);
this.loading = false;
this.repositories.forEach(repository => {
this.errorRepositories[repository.uuid] = true;
@ -226,23 +273,86 @@ export class GlobalStatusComponent implements OnInit {
loadOgBootStatus(): void {
this.isDhcp = false;
this.loadStatus(this.ogBootApiUrl, this.ogBootDiskUsage, this.ogBootServicesStatus, this.ogBootDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorOgBoot');
this.loadStatus(this.ogBootApiUrl, this.ogBootDiskUsage, this.ogBootServicesStatus, this.ogBootDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorOgBoot', false);
}
loadDhcpStatus(): void {
this.isDhcp = true;
this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorDhcp');
this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorDhcp', false);
}
onTabChange(event: MatTabChangeEvent): void {
if (event.tab.textLabel === 'OgBoot') {
this.loadOgBootStatus();
} else if (event.tab.textLabel === 'Dhcp') {
this.loadDhcpStatus();
} else if (event.tab.textLabel === 'Repositorios') {
if (this.repositories.length === 0) {
this.loadRepositories();
}
switch (event.index) {
case 0:
this.loadOgBootStatus();
break;
case 1:
this.loadDhcpStatus();
break;
case 2:
if (this.repositories.length === 0) {
this.loadRepositories(false);
}
break;
default:
break;
}
}
onRepositoryChange(repositoryUuid: string): void {
this.selectedRepositoryUuid = repositoryUuid;
if (repositoryUuid && !this.repositoryStatuses[repositoryUuid]) {
this.loadRepositoryStatus(repositoryUuid, (errorOccurred: boolean) => {
if (errorOccurred) {
this.errorRepositories[repositoryUuid] = true;
}
});
}
}
getSelectedRepositoryName(): string {
const selectedRepo = this.repositories.find(repo => repo.uuid === this.selectedRepositoryUuid);
return selectedRepo ? selectedRepo.name : '';
}
refreshAll(): void {
this.loading = true;
this.updateLastUpdateTime();
this.loadStatus(this.ogBootApiUrl, this.ogBootDiskUsage, this.ogBootServicesStatus, this.ogBootDiskUsageChartData, this.installedOgLives, false, 'errorOgBoot', true);
this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, true, 'errorDhcp', true);
this.loadRepositories(true);
this.syncSubnets();
this.syncTemplates();
this.syncOgLives();
this.toastService.success('Datos actualizados correctamente');
}
updateLastUpdateTime(): void {
const now = new Date();
this.lastUpdateTime = now.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
getCurrentTime(): string {
const now = new Date();
return now.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
retryRepositoryStatus(repositoryUuid: string): void {
this.loadRepositoryStatus(repositoryUuid, (errorOccurred: boolean) => {
this.errorRepositories[repositoryUuid] = errorOccurred;
if (!errorOccurred) {
this.toastService.success('Estado del repositorio actualizado correctamente');
} else {
this.toastService.error('Error al cargar el estado del repositorio');
}
});
}
}

View File

@ -1,84 +1,533 @@
/* ===== LAYOUT PRINCIPAL ===== */
.dashboard {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 0;
}
.disk-usage-container,
.ram-usage-container {
/* ===== SECCIÓN DE RECURSOS ===== */
.resources-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
}
/* Layout específico para repositorios - gráficas en una sola fila */
.resources-section.repository-layout {
grid-template-columns: 1fr !important;
gap: 1.5rem;
}
.resources-section.repository-layout .resource-card {
min-width: 0;
}
.resource-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.resource-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
opacity: 0;
transition: opacity 0.3s ease;
}
.resource-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.resource-card:hover::before {
opacity: 1;
}
.resource-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.disk-usage,
.ram-usage {
.resource-icon {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resource-icon mat-icon {
color: white;
font-size: 20px;
width: 20px;
height: 20px;
}
.resource-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.resource-content {
display: flex;
gap: 1.5rem;
align-items: center;
}
.chart-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.service-list,
.process-list {
margin-top: 0em;
margin-bottom: 0.5em;
}
.services-status,
.processes-status {
.resource-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.services-status li {
margin: 5px 0;
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #f1f3f4;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 0.9rem;
color: #6c757d;
font-weight: 500;
}
.info-value {
font-size: 0.9rem;
color: #2c3e50;
font-weight: 600;
}
.usage-percentage {
color: #667eea;
font-weight: 700;
}
/* ===== CPU USAGE DISPLAY ===== */
.cpu-usage-display {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.cpu-circle {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.cpu-percentage {
font-size: 1.8rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.25rem;
}
.cpu-label {
font-size: 0.8rem;
opacity: 0.9;
}
/* ===== SECCIÓN DE SERVICIOS ===== */
.services-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.service-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.service-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.service-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.processes-status li {
margin: 5px 0;
.service-icon {
background: linear-gradient(135deg, #28a745, #20c997);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.status-led {
width: 10px;
height: 10px;
.service-icon mat-icon {
color: white;
font-size: 20px;
width: 20px;
height: 20px;
}
.service-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.service-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.service-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.3s ease;
}
.service-item:hover {
background: #e9ecef;
transform: translateX(4px);
}
.service-status {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 10px;
transition: all 0.3s ease;
}
.status-led.active {
background-color: green;
.status-indicator.active {
background: linear-gradient(135deg, #28a745, #20c997);
box-shadow: 0 0 8px rgba(40, 167, 69, 0.4);
}
.status-led.inactive {
background-color: red;
.status-indicator.inactive {
background: linear-gradient(135deg, #dc3545, #c82333);
box-shadow: 0 0 8px rgba(220, 53, 69, 0.4);
}
.disk-title,
.ram-title {
margin-bottom: 0px;
.service-name {
font-size: 0.9rem;
font-weight: 500;
color: #2c3e50;
}
.service-title,
.process-title {
margin-top: 0px;
.service-state {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.25rem 0.75rem;
border-radius: 12px;
transition: all 0.3s ease;
}
table {
.service-state.active {
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
}
.service-state.inactive {
background: linear-gradient(135deg, #dc3545, #c82333);
color: white;
}
/* ===== SECCIÓN DE DATOS ===== */
.data-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.data-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.data-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.data-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.data-icon {
background: linear-gradient(135deg, #17a2b8, #138496);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.data-icon mat-icon {
color: white;
font-size: 20px;
width: 20px;
height: 20px;
}
.data-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.table-container {
overflow-x: auto;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: white;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
.data-table th {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
color: #495057;
padding: 1rem;
font-weight: 600;
text-align: left;
border-bottom: 2px solid #dee2e6;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
th {
background-color: #f4f4f4;
.data-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f3f4;
color: #2c3e50;
font-size: 0.9rem;
}
.data-table tr:hover {
background: #f8f9fa;
}
.data-table tr:last-child td {
border-bottom: none;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.dashboard {
gap: 1rem;
}
.resources-section {
grid-template-columns: 1fr;
gap: 1rem;
}
.services-section {
grid-template-columns: 1fr;
gap: 1rem;
}
.resource-content {
flex-direction: column;
gap: 1rem;
}
.resource-card,
.service-card,
.data-card {
padding: 1rem;
}
.cpu-circle {
width: 100px;
height: 100px;
}
.cpu-percentage {
font-size: 1.5rem;
}
.service-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.service-item:hover {
transform: none;
}
}
@media (max-width: 480px) {
.resource-header,
.service-header,
.data-header {
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.resource-icon,
.service-icon,
.data-icon {
width: 35px;
height: 35px;
}
.resource-icon mat-icon,
.service-icon mat-icon,
.data-icon mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.resource-title,
.service-title,
.data-title {
font-size: 1.1rem;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.data-table th,
.data-table td {
padding: 0.5rem;
font-size: 0.8rem;
}
}
/* ===== ANIMACIONES ===== */
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.resource-card,
.service-card,
.data-card {
animation: slideInUp 0.6s ease-out;
}
.resource-card:nth-child(1) { animation-delay: 0.1s; }
.resource-card:nth-child(2) { animation-delay: 0.2s; }
.resource-card:nth-child(3) { animation-delay: 0.3s; }
.service-card:nth-child(1) { animation-delay: 0.4s; }
.service-card:nth-child(2) { animation-delay: 0.5s; }
.data-card { animation-delay: 0.6s; }
/* ===== ESTILOS PARA LOS GRÁFICOS ===== */
::ng-deep .chart-container ngx-charts-pie-chart {
width: 100% !important;
height: auto !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
display: block !important;
margin-top: 1rem !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend .legend-labels {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend .legend-label {
display: flex !important;
align-items: center !important;
margin: 0.25rem 0 !important;
}

View File

@ -1,113 +1,206 @@
<app-loading [isLoading]="loading"></app-loading>
<div *ngIf="!loading" class="dashboard">
<!-- Disk Usage Section -->
<div class="disk-usage-container">
<h3 class="disk-title">{{ 'diskUsageTitle' | translate }}</h3>
<div class="disk-usage" joyrideStep="diskUsageStep" text="{{ 'diskUsageDescription' | translate }}">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="diskUsageChartData" [doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
<div class="disk-usage-info">
<p>{{ 'totalLabel' | translate }}: <strong>{{ isRepository ? diskUsage.total : formatBytes(diskUsage.total) }}</strong></p>
<p>{{ 'usedLabel' | translate }}: <strong>{{ isRepository ? diskUsage.used : formatBytes(diskUsage.used) }}</strong></p>
<p>{{ 'availableLabel' | translate }}: <strong>{{ isRepository ? diskUsage.available : formatBytes(diskUsage.available) }}</strong></p>
<p>{{ 'usedPercentageLabel' | translate }}: <strong>{{ isRepository ? diskUsage.used_percentage : diskUsage.percentage }}</strong></p>
<!-- Sección de uso de recursos -->
<div class="resources-section" [ngClass]="{'repository-layout': isRepository}">
<!-- Disk Usage Section -->
<div class="resource-card disk-usage-container">
<div class="resource-header">
<div class="resource-icon">
<mat-icon>storage</mat-icon>
</div>
<h3 class="resource-title">{{ 'diskUsageTitle' | translate }}</h3>
</div>
<div class="resource-content">
<div class="chart-container" joyrideStep="diskUsageStep" text="{{ 'diskUsageDescription' | translate }}">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="diskUsageChartData" [doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
</div>
<div class="resource-info">
<div class="info-item">
<span class="info-label">{{ 'totalLabel' | translate }}:</span>
<span class="info-value">{{ isRepository ? diskUsage.total : formatBytes(diskUsage.total) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedLabel' | translate }}:</span>
<span class="info-value">{{ isRepository ? diskUsage.used : formatBytes(diskUsage.used) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'availableLabel' | translate }}:</span>
<span class="info-value">{{ isRepository ? diskUsage.available : formatBytes(diskUsage.available) }}</span>
</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>
</div>
</div>
</div>
</div>
<!-- RAM Usage Section -->
<div class="resource-card ram-usage-container" *ngIf="isRepository">
<div class="resource-header">
<div class="resource-icon">
<mat-icon>memory</mat-icon>
</div>
<h3 class="resource-title">{{ 'RamUsage' | translate }}</h3>
</div>
<div class="resource-content">
<div class="chart-container">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="ramUsageChartData" [doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
</div>
<div class="resource-info">
<div class="info-item">
<span class="info-label">{{ 'totalLabel' | translate }}:</span>
<span class="info-value">{{ ramUsage.total }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedLabel' | translate }}:</span>
<span class="info-value">{{ ramUsage.used }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'availableLabel' | translate }}:</span>
<span class="info-value">{{ ramUsage.available }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedPercentageLabel' | translate }}:</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">
<mat-icon>speed</mat-icon>
</div>
<h3 class="resource-title">{{ 'CpuUsage' | translate }}</h3>
</div>
<div class="resource-content">
<div class="cpu-usage-display">
<div class="cpu-circle">
<div class="cpu-percentage">{{ cpuUsage.used_percentage }}</div>
<div class="cpu-label">Uso actual</div>
</div>
</div>
</div>
</div>
</div>
<!-- RAM Usage Section -->
<div class="ram-usage-container" *ngIf="isRepository">
<h3 class="ram-title">{{ 'RamUsage' | translate }}</h3>
<div class="ram-usage">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="ramUsageChartData" [doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
<div class="ram-usage-info">
<p>{{ 'totalLabel' | translate }}: <strong>{{ ramUsage.total }}</strong></p>
<p>{{ 'usedLabel' | translate }}: <strong>{{ ramUsage.used }}</strong></p>
<p>{{ 'availableLabel' | translate }}: <strong>{{ ramUsage.available }}</strong></p>
<p>{{ 'usedPercentageLabel' | translate }}: <strong>{{ ramUsage.used_percentage }}</strong></p>
<!-- 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">
<mat-icon>settings</mat-icon>
</div>
<h3 class="service-title">{{ 'servicesTitle' | translate }}</h3>
</div>
<div class="service-list">
<div class="service-item" *ngFor="let service of getServices()">
<div class="service-status">
<span class="status-indicator"
[ngClass]="{ 'active': service.status === 'active', 'inactive': service.status !== 'active' }"></span>
<span class="service-name">{{ service.name }}</span>
</div>
<span class="service-state" [ngClass]="{ 'active': service.status === 'active', 'inactive': service.status !== 'active' }">
{{ service.status | translate }}
</span>
</div>
</div>
</div>
<!-- Processes Status Section -->
<div class="service-card processes-status" *ngIf="isRepository">
<div class="service-header">
<div class="service-icon">
<mat-icon>list_alt</mat-icon>
</div>
<h3 class="service-title">{{ 'processes' | translate }}</h3>
</div>
<div class="service-list">
<div class="service-item" *ngFor="let process of getProcesses()">
<div class="service-status">
<span class="status-indicator"
[ngClass]="{ 'active': process.status === 'running', 'inactive': process.status !== 'running' }"></span>
<span class="service-name">{{ process.name }}</span>
</div>
<span class="service-state" [ngClass]="{ 'active': process.status === 'running', 'inactive': process.status !== 'running' }">
{{ process.status }}
</span>
</div>
</div>
</div>
</div>
<!-- CPU Usage Section -->
<div class="cpu-usage-container" *ngIf="isRepository">
<h3 class="cpu-title">{{ 'CpuUsage' | translate }}</h3>
<div class="cpu-usage">
<p>{{ 'usedLabel' | translate }}: <strong>{{ cpuUsage.used_percentage }}</strong></p>
<!-- 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">
<mat-icon>computer</mat-icon>
</div>
<h3 class="data-title">{{ 'InstalledOglivesTitle' | translate }}</h3>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>{{ 'idLabel' | translate }}</th>
<th>{{ 'kernelLabel' | translate }}</th>
<th>{{ 'architectureLabel' | translate }}</th>
<th>{{ 'revisionLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of installedOgLives">
<td>{{ item.id }}</td>
<td>{{ item.kernel }}</td>
<td>{{ item.architecture }}</td>
<td>{{ item.revision }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Services Status Section -->
<div class="services-status" joyrideStep="servicesStatusStep" text="{{ 'servicesStatusDescription' | translate }}">
<h3 class="service-title">{{ 'servicesTitle' | translate }}</h3>
<ul class="service-list">
<li *ngFor="let service of getServices()">
<span class="status-led"
[ngClass]="{ 'active': service.status === 'active', 'inactive': service.status !== 'active' }"></span>
{{ service.name }}: {{ service.status | translate }}
</li>
</ul>
</div>
<!-- Processes Status Section -->
<div class="processes-status" *ngIf="isRepository">
<h3 class="process-title">{{ 'processes' | translate }}</h3>
<ul class="process-list">
<li *ngFor="let process of getProcesses()">
<span class="status-led"
[ngClass]="{ 'active': process.status === 'running', 'inactive': process.status !== 'running' }"></span>
{{ process.name }}: {{ process.status }}
</li>
</ul>
</div>
<!-- Installed OgLives / Subnets Section -->
<div *ngIf="!isRepository && !isDhcp">
<h3>{{ 'InstalledOglivesTitle' | translate }}</h3>
<table>
<thead>
<tr>
<th>{{ 'idLabel' | translate }}</th>
<th>{{ 'kernelLabel' | translate }}</th>
<th>{{ 'architectureLabel' | translate }}</th>
<th>{{ 'revisionLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of installedOgLives">
<td>{{ item.id }}</td>
<td>{{ item.kernel }}</td>
<td>{{ item.architecture }}</td>
<td>{{ item.revision }}</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="isDhcp">
<h3>{{ 'subnets' | translate }}</h3>
<table>
<thead>
<tr>
<th>{{ 'idLabel' | translate }}</th>
<th>{{ 'bootFileNameLabel' | translate }}</th>
<th>{{ 'nextServerLabel' | translate }}</th>
<th>{{ 'ipLabel' | translate }}</th>
<th>{{ 'clientsLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of subnets">
<td>{{ item.id }}</td>
<td>{{ item['boot-file-name'] }}</td>
<td>{{ item['next-server'] }}</td>
<td>{{ item.subnet }}</td>
<td>{{ item.reservations.length }}</td>
</tr>
</tbody>
</table>
<!-- Subnets Section -->
<div class="data-card" *ngIf="isDhcp">
<div class="data-header">
<div class="data-icon">
<mat-icon>router</mat-icon>
</div>
<h3 class="data-title">{{ 'subnets' | translate }}</h3>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>{{ 'idLabel' | translate }}</th>
<th>{{ 'bootFileNameLabel' | translate }}</th>
<th>{{ 'nextServerLabel' | translate }}</th>
<th>{{ 'ipLabel' | translate }}</th>
<th>{{ 'clientsLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of subnets">
<td>{{ item.id }}</td>
<td>{{ item['boot-file-name'] }}</td>
<td>{{ item['next-server'] }}</td>
<td>{{ item.subnet }}</td>
<td>{{ item.reservations.length }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@ -31,7 +31,10 @@
</div>
<div class="table-header-container">
<h2 class="title" i18n="@@adminImagesTitle">Discos/Particiones</h2>
<mat-chip> {{ clientData.firmwareType }}</mat-chip>
<mat-chip *ngIf="clientData.firmwareType" class="firmware-chip">
<mat-icon>memory</mat-icon>
{{ clientData.firmwareType }}
</mat-chip>
</div>
<div class="disk-container">

View File

@ -1,105 +1,677 @@
.title {
font-size: 24px;
}
.calendar-button-row {
display: flex;
justify-content: flex-start;
margin-top: 16px;
}
.divider {
margin: 20px 0;
}
.lists-container {
padding: 16px;
}
.card.unidad-card {
height: 100%;
box-sizing: border-box;
}
table {
width: 100%;
margin-top: 50px;
background-color: #eaeff6;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0 5px;
box-sizing: border-box;
}
/* Contenedor principal modernizado */
.select-container {
gap: 16px;
gap: 24px;
width: 100%;
box-sizing: border-box;
padding: 20px;
padding: 32px;
background: white !important;
border-radius: 4px;
margin: 20px 0;
align-items: center;
}
/* Secciones del formulario */
.form-section {
background: white !important;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
border: 1px solid #bbdefb;
}
.form-section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.form-section-title mat-icon {
color: #2196f3;
}
.selector {
display: flex;
gap: 16px;
gap: 20px;
width: 100%;
margin-top: 30px;
margin-top: 16px;
box-sizing: border-box;
align-items: start;
}
.half-width {
flex: 1;
max-width: 50%;
max-width: calc(50% - 10px);
}
.search-string {
flex: 2;
padding: 5px;
}
.search-boolean {
.full-width {
flex: 1;
padding: 5px;
width: 100%;
}
/* Header modernizado */
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
padding: 24px 32px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
/* Estilos modernos para el badge de destino */
.destination-info {
margin-top: 12px;
}
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
}
.button-row {
display: flex;
padding-right: 1em;
gap: 12px;
padding-right: 0;
}
/* Tabla de particiones modernizada */
.partition-table-container {
background-color: #eaeff6;
padding: 20px;
background: white;
padding: 24px;
border-radius: 12px;
margin-top: 20px;
margin-top: 24px;
}
.partition-table-container h3 {
margin: 0 0 20px 0;
color: #333;
font-weight: 600;
font-size: 18px;
}
.repository-label {
font-weight: 500;
margin-right: 8px;
}
mat-chip {
margin-top: 8px !important;
border-radius: 20px !important;
}
mat-icon {
margin-right: 4px;
}
/* Botón de crear repositorio modernizado */
.create-repository-button {
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;
}
.create-repository-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.create-repository-button mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* Botones modernizados */
.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);
}
/* Campos de formulario modernizados */
mat-form-field {
margin-bottom: 8px;
}
::ng-deep .mat-form-field-appearance-fill .mat-form-field-flex {
border-radius: 8px;
background-color: white !important;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
::ng-deep .mat-form-field-appearance-fill.mat-focused .mat-form-field-flex {
background-color: white;
border-color: #2196f3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
/* Overlay de carga para creación de repositorio */
.creating-repository-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
color: white;
backdrop-filter: blur(4px);
}
.creating-repository-overlay p {
margin-top: 16px;
font-size: 16px;
font-weight: 500;
}
/* Estilo para hacer el backdrop no clickeable */
::ng-deep .non-clickable-backdrop {
pointer-events: none !important;
}
/* Responsive design */
@media (max-width: 768px) {
.select-container {
padding: 16px;
}
.header-container {
padding: 16px;
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.selector {
flex-direction: column;
gap: 16px;
}
.half-width {
max-width: 100%;
}
.create-repository-button {
min-width: 100%;
margin-left: 0;
}
.button-row {
justify-content: center;
}
.destination-badge {
padding: 10px 14px;
border-radius: 10px;
}
.destination-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 10px;
}
.destination-value {
max-width: 150px;
font-size: 13px;
}
.destination-label {
font-size: 10px;
}
}
/* Estilos para elementos específicos */
.unit-name {
font-weight: 500;
color: #2c3e50;
}
/* Estilos para las opciones de acción Git */
.git-action-selector {
margin: 24px 0;
padding: 20px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
border: 1px solid #dee2e6;
}
.action-chips-container {
margin-bottom: 16px;
}
::ng-deep .action-chip {
margin: 8px !important;
padding: 12px 20px !important;
border-radius: px !important;
font-weight: 500 !important;
font-size: 14px !important;
transition: all 0.3s ease !important;
border: 2px solid transparent !important;
background: white !important;
color: #6c757d !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
min-height: 48px !important;
}
::ng-deep .action-chip:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
::ng-deep .action-chip.mat-mdc-chip-selected {
border-color: #667eea !important;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
}
::ng-deep .create-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%) !important;
color: white !important;
}
::ng-deep .update-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%) !important;
color: white !important;
}
::ng-deep .action-chip mat-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
.action-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(102, 126, 234, 0.1);
border-radius: 8px;
border-left: 4px solid #667eea;
color: #495057;
font-size: 14px;
}
.action-hint mat-icon {
color: #667eea;
font-size: 16px;
width: 16px;
height: 16px;
}
.git-action-section {
background: white !important;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
border-left: 4px solid #667eea;
}
/* Eliminar sombra de la tabla */
.mat-elevation-z8 {
box-shadow: none !important;
}
/* Animaciones para transiciones de formulario */
.form-transition {
transition: all 0.3s ease-in-out;
opacity: 1;
transform: translateY(0);
}
.form-transition.ng-enter {
opacity: 0;
transform: translateY(10px);
}
.form-transition.ng-enter-active {
opacity: 1;
transform: translateY(0);
}
.form-transition.ng-leave {
opacity: 1;
transform: translateY(0);
}
.form-transition.ng-leave-active {
opacity: 0;
transform: translateY(-10px);
}
/* Estilos para los formularios específicos */
.git-form-section {
background: white;
border-radius: 4px;
padding: 20px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
.git-form-section:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
/* Estilos para la sección de repositorio Git */
.git-repository-section {
background: white !important;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #e9ecef;
}
.repository-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
}
.repository-header h4 {
margin: 0;
color: #2c3e50;
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.repository-selector {
display: flex;
gap: 20px;
align-items: flex-start;
}
.repository-field {
flex: 0 0 300px;
min-width: 250px;
}
.repository-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 8px;
color: #6c757d;
font-size: 13px;
line-height: 1.4;
}
.info-item mat-icon {
color: #667eea;
font-size: 16px;
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
}
.create-repository-button {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: none;
padding: 10px 16px;
border-radius: 6px;
font-weight: 500;
font-size: 14px;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.2);
}
.create-repository-button:hover:not(:disabled) {
background: linear-gradient(135deg, #218838 0%, #1ea085 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.create-repository-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.create-repository-button mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.no-repositories-hint {
display: flex;
align-items: flex-start;
gap: 8px;
color: #dc3545;
font-weight: 500;
font-size: 13px;
padding: 4px 4px 4px 12px;
background: rgba(220, 53, 69, 0.1);
border-radius: 6px;
border-left: 3px solid #dc3545;
line-height: 1.4;
}
.no-repositories-hint mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
}
/* Estilos para el hint del formulario */
::ng-deep .mat-form-field-hint {
display: flex !important;
align-items: center !important;
gap: 6px !important;
color: #6c757d !important;
font-size: 12px !important;
}
::ng-deep .mat-form-field-hint mat-icon {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #667eea !important;
}
/* Responsive */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 20px;
text-align: center;
}
.button-row {
flex-wrap: wrap;
justify-content: center;
}
.selector {
flex-direction: column;
}
.half-width {
min-width: auto;
}
.clients-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.input-group {
grid-template-columns: 1fr;
}
.select-container {
padding: 0 16px;
}
.form-section {
padding: 20px;
}
/* Responsive para repositorio Git */
.repository-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.repository-selector {
flex-direction: column;
gap: 16px;
}
.repository-field {
flex: none;
width: 100%;
min-width: auto;
}
.repository-info {
flex: none;
}
.create-repository-button {
width: 100%;
justify-content: center;
}
}
/* Botón flotante para scroll hacia arriba */
.scroll-to-top-button {
position: fixed;
bottom: 30px;
left: 30px;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-to-top-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Animación de entrada/salida */
.scroll-to-top-button {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive para el botón */
@media (max-width: 768px) {
.scroll-to-top-button {
bottom: 20px;
left: 20px;
}
}

View File

@ -1,87 +1,205 @@
<app-loading [isLoading]="loading"></app-loading>
<!-- Overlay de carga para creación de repositorio -->
<app-modal-overlay
[isVisible]="creatingRepository"
message="Creando repositorio...">
</app-modal-overlay>
<div class="header-container">
<div class="header-container-title">
<h2 >
<h2>
Crear imagen desde {{ clientName }}
</h2>
<div class="destination-info">
<div class="destination-badge">
<mat-icon class="destination-icon">cloud_upload</mat-icon>
<div class="destination-content">
<span class="destination-label">Destino</span>
<span class="destination-value">{{ selectedRepository?.name || 'No hay repositorio asociado' }}</span>
</div>
</div>
</div>
</div>
<div class="button-row">
<button class="action-button" [disabled]="!selectedPartition" (click)="save()">Ejecutar</button>
<button class="action-button" id="execute-button" [disabled]="!selectedPartition" (click)="save()">Ejecutar</button>
</div>
</div>
<mat-divider></mat-divider>
<div class="select-container">
<div class="selector">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Tipo de imagen</mat-label>
<mat-select [(ngModel)]="imageType" class="full-width" (selectionChange)="onImageTypeSelected($event.value)">
<mat-option [value]="'monolithic'">Monolítica</mat-option>
<!--
<mat-option [value]="'git'">Git</mat-option>
-->
</mat-select>
</mat-form-field>
<!-- Sección: Configuración de tipo de imagen -->
<div class="form-section">
<div class="form-section-title">
<mat-icon>settings</mat-icon>
Configuración de tipo de imagen
</div>
<div class="selector">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Tipo de imagen</mat-label>
<mat-select [(ngModel)]="imageType" class="full-width" (selectionChange)="onImageTypeSelected($event.value)">
<mat-option [value]="'monolithic'">Monolítica</mat-option>
<mat-option [value]="'git'">Git</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="selector">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Nombre canónico</mat-label>
<input matInput [disabled]="selectedImage" [(ngModel)]="name" placeholder="Nombre canónico. En minúscula y sin espacios" required>
</mat-form-field>
<!-- 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">
<button *ngIf="imageType === 'git'"
class="create-repository-button"
(click)="openCreateRepositoryModal()"
[disabled]="creatingRepository">
<mat-icon>add</mat-icon>
<span>Crear nuevo repositorio / SO</span>
</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>
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [disabled]="!imageType" [(ngModel)]="selectedImage" name="selectedImage" (selectionChange)="resetCanonicalName()" required>
<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>Seleccione la imagen para sobreescribir si se requiere. </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
</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="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<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>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<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" #partitionSection id="partition-selection">
<div class="form-section-title">
<mat-icon>storage</mat-icon>
Selección de partición
</div>
<div class="partition-table-container">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</div>
</div>
<app-scroll-to-top
[threshold]="200"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -1,10 +1,13 @@
import {Component, EventEmitter, OnInit, Output} from '@angular/core';
import {Component, EventEmitter, OnInit, Output, ViewChild, ElementRef} from '@angular/core';
import { HttpClient } from "@angular/common/http";
import { ToastrService } from "ngx-toastr";
import { ActivatedRoute, Router } from "@angular/router";
import { MatTableDataSource } from "@angular/material/table";
import { SelectionModel } from "@angular/cdk/collections";
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";
@Component({
selector: 'app-create-image',
@ -14,18 +17,32 @@ import { ConfigService } from '@services/config.service';
export class CreateClientImageComponent implements OnInit{
baseUrl: string;
@Output() dataChange = new EventEmitter<any>();
@ViewChild('partitionSection', { static: false }) partitionSection!: ElementRef;
errorMessage = '';
clientId: string | null = null;
partitions: any[] = [];
images: any[] = [];
clientName: string = '';
selectedPartition: any = null;
private _selectedPartition: any = null;
name: string = '';
client: any = null;
loading: boolean = false;
selectedImage: any = null;
imageType : string = 'monolithic';
private _imageType: string = 'monolithic';
selectedRepository: any = null;
gitRepositories: any[] = [];
selectedGitRepository: any = null;
gitImageRepositories: any[] = [];
gitImageName: string = '';
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>();
columns = [
{
@ -69,11 +86,13 @@ export class CreateClientImageComponent implements OnInit{
private configService: ConfigService,
private route: ActivatedRoute,
private router: Router,
private dialog: MatDialog
) {
this.baseUrl = this.configService.apiUrl;
}
ngOnInit() {
console.log('CreateImageComponent ngOnInit ejecutado');
this.clientId = this.route.snapshot.paramMap.get('id');
this.loadPartitions();
this.loadImages();
@ -86,6 +105,7 @@ export class CreateClientImageComponent implements OnInit{
if (response.partitions) {
this.client = response;
this.clientName = response.name;
this.selectedRepository = response.repository;
this.dataSource.data = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
@ -100,7 +120,6 @@ export class CreateClientImageComponent implements OnInit{
onImageTypeSelected(event: any) {
this.imageType = event;
this.loadImages();
}
loadImages() {
@ -115,45 +134,285 @@ export class CreateClientImageComponent implements OnInit{
);
}
loadGitRepositories() {
this.loadingGitRepositories = true;
const url = `${this.baseUrl}/git-repositories?repository=${this.selectedRepository.id}&page=1&itemsPerPage=100`;
return this.http.get(url).subscribe(
(response: any) => {
this.gitRepositories = response['hydra:member'];
this.loadingGitRepositories = false;
},
(error) => {
console.error('Error al cargar los repositorios git:', error);
this.loadingGitRepositories = false;
}
);
}
loadGitImageRepositories(gitRepository: any) {
this.loadingGitImageRepositories = true;
const url = `${this.baseUrl}/git-image-repositories?gitRepository.id=${gitRepository.id}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
(response: any) => {
this.gitImageRepositories = response['hydra:member'];
this.loadingGitImageRepositories = false;
},
(error) => {
console.error('Error al cargar las imágenes de repositorio git:', error);
this.loadingGitImageRepositories = false;
}
);
}
onGitRepositorySelected(gitRepository: any) {
this.selectedGitRepository = gitRepository;
this.selectedExistingImage = null;
this.existingImages = [];
if (gitRepository) {
this.loadGitImageRepositories(gitRepository);
} 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);
}
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);
}
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);
}
loadExistingImages() {
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(
(response: any) => {
this.existingImages = response['hydra:member'] || [];
this.loadingExistingImages = false;
},
(error) => {
console.error('Error al cargar las imágenes existentes:', error);
this.loadingExistingImages = false;
this.toastService.error('Error al cargar las imágenes existentes');
}
);
}
resetGitSelections() {
this.selectedGitRepository = null;
this.selectedExistingImage = null;
this.gitImageName = '';
this.gitAction = 'create';
this.existingImages = [];
this.gitRepositories = [];
this.gitImageRepositories = [];
this.selectedImage = null;
this.name = '';
this.monolithicAction = 'create';
}
resetCanonicalName() {
this.name = this.selectedImage ? this.selectedImage.name : '';
}
save(): void {
this.loading = true;
if (!this.selectedPartition) {
this.toastService.error('Debes seleccionar una partición');
this.loading = false;
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) {
this.toastService.error('Debes introducir un nombre canónico para la imagen');
return;
}
if (this.monolithicAction === 'update' && !this.selectedImage) {
this.toastService.error('Debes seleccionar una imagen para actualizar');
return;
}
}
if (this.selectedImage) {
this.toastService.warning('Aviso: Está seleccionando una imagen previamente creada. Se procede a crear un backup de la misma. ');
}
const payload = {
client: `/clients/${this.clientId}`,
name: this.name,
partition: this.selectedPartition['@id'],
source: 'assistant',
type: this.imageType,
selectedImage: this.selectedImage?.['@id']
};
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
});
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
this.loading = true;
let payload: any = {
client: `/clients/${this.clientId}`,
partition: this.selectedPartition['@id'],
source: 'assistant',
type: this.imageType,
queue: result
};
this.http.post(`${this.baseUrl}/images`, payload)
.subscribe({
next: (response) => {
this.toastService.success('Petición de creación de imagen enviada');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
this.loading = false;
if (this.imageType === 'git') {
payload.gitRepository = this.selectedGitRepository.name
payload.name = this.selectedGitRepository.name;
if (this.gitAction === 'create') {
payload.action = 'create';
} else {
payload.action = 'update';
}
} else {
if (this.monolithicAction === 'create') {
payload.name = this.name;
payload.action = 'create';
} else {
payload.selectedImage = this.selectedImage['@id'];
payload.action = 'update';
}
}
this.http.post(`${this.baseUrl}/images`, payload)
.subscribe({
next: (response) => {
let actionText = 'creación';
if (this.imageType === 'git' && this.gitAction === 'update') {
actionText = 'actualización';
} else if (this.imageType === 'monolithic' && this.monolithicAction === 'update') {
actionText = 'actualización';
}
this.toastService.success(`Petición de ${actionText} de imagen enviada`);
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
this.loading = false;
}
});
}
);
});
}
openCreateRepositoryModal(): void {
this.creatingRepository = true;
const dialogRef = this.dialog.open(CreateRepositoryModalComponent, {
width: '600px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
data: {
clientRepository: this.selectedRepository
}
});
dialogRef.afterClosed().subscribe(result => {
this.creatingRepository = false;
if (result) {
this.loadGitRepositories();
setTimeout(() => {
const newRepository = this.gitRepositories.find(repo => repo['@id'] === result['@id']);
if (newRepository) {
this.selectedGitRepository = newRepository;
this.onGitRepositorySelected(newRepository);
}
}, 200);
}
});
}
get imageType(): string {
return this._imageType;
}
set imageType(value: string) {
this._imageType = value;
this.loadImages();
if (value === 'git') {
this.loadGitRepositories();
this.selectedImage = null;
this.name = '';
this.monolithicAction = 'create';
} else {
this.resetGitSelections();
}
}
onGitImageRepositorySelected(gitImageRepository: any) {
this.selectedExistingImage = gitImageRepository;
this.existingImages = [];
if (gitImageRepository) {
this.loadExistingImages();
}
}
scrollToPartitionSection() {
const partitionSection = document.getElementById('partition-selection');
if (partitionSection) {
partitionSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
get selectedPartition(): any {
return this._selectedPartition;
}
set selectedPartition(value: any) {
this._selectedPartition = value;
}
}

View File

@ -0,0 +1,40 @@
.dialog-content {
display: flex;
flex-direction: column;
padding: 40px;
}
.repository-form {
width: 100%;
display: flex;
flex-direction: column;
}
.form-field {
width: 100%;
margin-top: 16px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
@media (max-width: 600px) {
.form-field {
width: 100%;
}
.dialog-actions {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
}

View File

@ -0,0 +1,17 @@
<app-loading [isLoading]="loading"></app-loading>
<h2 mat-dialog-title>Crear nuevo repositorio de imágenes git</h2>
<mat-dialog-content class="dialog-content">
<form [formGroup]="repositoryForm" (ngSubmit)="save()" class="repository-form">
<mat-form-field appearance="fill" class="form-field">
<mat-label>Nombre del repositorio</mat-label>
<input matInput formControlName="name" required>
</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)="save()">Guardar</button>
</div>

View File

@ -0,0 +1,64 @@
import { Component, OnInit, Inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { HttpClient } from "@angular/common/http";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { ToastrService } from "ngx-toastr";
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-create-repository-modal',
templateUrl: './create-repository-modal.component.html',
styleUrl: './create-repository-modal.component.css'
})
export class CreateRepositoryModalComponent implements OnInit {
baseUrl: string;
repositoryForm: FormGroup<any>;
loading: boolean = false;
clientRepository: any = null;
constructor(
private fb: FormBuilder,
private http: HttpClient,
public dialogRef: MatDialogRef<CreateRepositoryModalComponent>,
private toastService: ToastrService,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
this.clientRepository = this.data?.clientRepository || null;
this.repositoryForm = this.fb.group({
name: [null, Validators.required],
});
}
ngOnInit() {
// El componente se inicializa
}
save(): void {
if (this.repositoryForm.valid) {
this.loading = true;
const payload = {
name: this.repositoryForm.value.name,
repository: this.clientRepository ? this.clientRepository.id : null
};
this.http.post(`${this.baseUrl}/git-repositories`, payload).subscribe({
next: (response) => {
this.toastService.success('Repositorio creado correctamente');
this.dialogRef.close(response);
},
error: (error) => {
this.toastService.error(error.error?.['hydra:description'] || 'Error al crear el repositorio');
this.loading = false;
}
});
} else {
this.toastService.error('Por favor, complete todos los campos requeridos');
}
}
close(): void {
this.dialogRef.close();
}
}

View File

@ -1,101 +1,140 @@
.divider {
margin: 20px 0;
}
table {
width: 100%;
margin-top: 50px;
background-color: #eaeff6;
}
.search-container {
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0 5px;
box-sizing: border-box;
padding: 24px 32px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
}
.option-container {
margin: 20px 0;
width: 100%;
.header-container-title {
flex-grow: 1;
text-align: left;
}
.deploy-container {
.header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
.header-container-title h4 {
margin: 0;
font-size: 16px;
opacity: 0.9;
font-weight: 400;
}
.button-row {
display: flex;
justify-content: space-between;
padding-right: 1em;
gap: 12px;
align-items: center;
width: 100%;
padding: 5px;
gap: 10px;
}
.options-container {
padding: 10px;
.action-button {
margin-top: 10px;
margin-bottom: 10px;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
}
.action-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Contenedor principal */
.select-container {
background: white !important;
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.input-group {
/* Secciones del formulario */
.form-section {
background: white !important;
border-radius: 16px;
padding: 32px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #bbdefb;
}
.form-section-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
color: #2c3e50;
padding-bottom: 16px;
border-bottom: 2px solid #f8f9fa;
}
.form-section-title mat-icon {
color: #667eea;
font-size: 24px;
width: 24px;
height: 24px;
}
/* Selectores */
.selector {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
}
mat-option .unit-name {
display: block;
}
.input-field {
flex: 1 1 calc(33.33% - 16px);
.half-width {
flex: 1;
min-width: 250px;
}
.full-width {
width: 100%;
margin-bottom: 16px;
}
.search-string {
flex: 2;
padding: 5px;
/* Campos de formulario */
mat-form-field {
width: 100%;
margin-bottom: 8px;
}
.search-boolean {
flex: 1;
padding: 5px;
::ng-deep .mat-form-field-appearance-fill .mat-form-field-flex {
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
::ng-deep .mat-form-field-appearance-fill.mat-focused .mat-form-field-flex {
background-color: white;
border-color: #2196f3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
/* Grid de clientes */
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
gap: 12px;
margin-top: 20px;
}
.client-item {
@ -103,117 +142,381 @@ mat-option .unit-name {
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
position: relative;
padding: 8px;
padding: 12px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
transition: all 0.3s ease;
border: 2px solid transparent;
}
::ng-deep .custom-tooltip {
white-space: pre-line !important;
max-width: 200px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.selected-client {
background-color: #a0c2e5 !important; /* Azul */
color: white !important;
background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%);
color: white;
border-color: #667eea;
}
.selected-client .client-name,
.selected-client .client-ip {
color: white;
}
.client-image {
width: 32px;
height: 32px;
margin-bottom: 8px;
}
.client-details {
margin-top: 4px;
margin-bottom: 12px;
}
.client-name {
font-size: 0.9em;
font-size: 12px;
font-weight: 600;
color: #333;
margin-bottom: 5px;
color: #2c3e50;
margin-bottom: 2px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
font-size: 10px;
color: #6c757d;
display: block;
font-size: 0.9em;
color: #666;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.button-row {
display: flex;
padding-right: 1em;
margin-bottom: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tabla de particiones */
.partition-table-container {
background-color: #eaeff6;
padding: 20px;
background: white !important;
border-radius: 12px;
padding: 24px;
margin-top: 20px;
}
table {
width: 100%;
background: white;
border-radius: 8px;
overflow: hidden;
}
::ng-deep .mat-table {
background: white;
}
::ng-deep .mat-header-cell {
background: white !important;
color: #2c3e50;
font-weight: 600;
padding: 16px;
}
::ng-deep .mat-cell {
padding: 16px;
color: #495057;
}
/* Opciones avanzadas */
.input-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.input-field {
width: 100%;
}
/* Mensajes de error */
.error-message {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
padding: 16px 20px;
border-radius: 8px;
margin-top: 16px;
font-weight: 500;
box-shadow: 0 4px 16px rgba(255, 107, 107, 0.3);
}
/* Instrucciones */
.instructions-box {
margin-bottom: 20px;
}
.instructions-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
::ng-deep .instructions-card .mat-card-title {
color: #2c3e50;
font-weight: 600;
padding: 20px 20px 0 20px;
}
::ng-deep .instructions-card .mat-card-content {
padding: 20px;
}
.instructions-card pre {
background: white !important;
padding: 16px;
border-radius: 8px;
border: 1px solid #e9ecef;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
/* Tooltip personalizado */
::ng-deep .custom-tooltip {
background: rgba(0, 0, 0, 0.9) !important;
color: white !important;
padding: 12px !important;
border-radius: 8px !important;
font-size: 12px !important;
max-width: 250px !important;
white-space: pre-line !important;
}
/* Responsive */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.button-row {
justify-content: center;
}
.selector {
flex-direction: column;
}
.half-width {
min-width: auto;
}
.clients-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.client-card {
padding: 8px;
}
.client-image {
width: 24px;
height: 24px;
margin-bottom: 6px;
}
.client-name {
font-size: 11px;
}
.client-ip {
font-size: 9px;
}
.input-group {
grid-template-columns: 1fr;
}
.select-container {
padding: 0 16px;
}
.form-section {
padding: 20px;
}
.destination-badge {
padding: 10px 14px;
border-radius: 10px;
}
.destination-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 10px;
}
.destination-value {
max-width: 150px;
font-size: 13px;
}
.destination-label {
font-size: 10px;
}
}
/* Estilos para elementos específicos */
.unit-name {
font-weight: 500;
color: #2c3e50;
}
/* Eliminar sombra de la tabla */
.mat-elevation-z8 {
box-shadow: none !important;
}
/* Estilos para el expansion panel */
::ng-deep .mat-expansion-panel {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
border-radius: 12px !important;
margin-bottom: 20px;
background: #f7fbff !important;
border: 1px solid #bbdefb !important;
}
::ng-deep .mat-expansion-panel-header {
padding: 20px 24px !important;
border-radius: 12px !important;
}
::ng-deep .mat-expansion-panel-header-title {
font-weight: 600 !important;
color: #2c3e50 !important;
}
::ng-deep .mat-expansion-panel-header-description {
color: #6c757d !important;
}
/* Otros estilos */
.divider {
margin: 20px 0;
}
.disabled-client {
pointer-events: none;
opacity: 0.5;
}
.error-message {
background-color: #de2323;
padding: 20px;
border-radius: 12px;
margin-top: 20px;
color: white;
font-weight: bold;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.instructions-box {
margin-top: 15px;
background-color: #f5f5f5;
border: 1px solid #ccc;
padding: 15px;
border-radius: 6px;
}
.instructions-textarea textarea {
font-family: monospace;
white-space: pre;
}
.instructions-card {
background-color: #f5f5f5;
box-shadow: none !important;
margin-top: 15px;
/* Estilo para hacer el backdrop no clickeable */
::ng-deep .non-clickable-backdrop {
pointer-events: none;
}
/* Estilos modernos para el badge de destino */
.destination-info {
margin-top: 12px;
}
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
}
.filters-row {
display: flex;
gap: 20px;
align-items: flex-end;
margin-bottom: 20px;
}
.git-gap {
gap: 40px;
}
.monolithic-row {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 20px;
}
.monolithic-row .half-width {
flex: 1 1 200px;
min-width: 200px;
}
.monolithic-row .full-width {
flex: 2;
}
@media (max-width: 768px) {
.monolithic-row .full-width {
flex: 2;
}
}

View File

@ -5,12 +5,18 @@
<h2>
{{ 'deployImage' | translate }}
</h2>
<h4>
{{ runScriptTitle }}
</h4>
<div class="destination-info">
<div class="destination-badge">
<mat-icon class="destination-icon">cloud_download</mat-icon>
<div class="destination-content">
<span class="destination-label">Destino</span>
<span class="destination-value">{{ runScriptTitle }}</span>
</div>
</div>
</div>
</div>
<div class="button-row">
<button class="action-button"
<button class="action-button" id="execute-button"
[disabled]="!isFormValid()"
(click)="save()">Ejecutar</button>
</div>
@ -21,18 +27,15 @@
</button>
</div>
<div>
<button mat-stroked-button color="accent"
<div class="button-row">
<button class="action-button" color="accent"
[disabled]="!isFormValid()"
(click)="openScheduleModal()">
<mat-icon>schedule</mat-icon> Opciones de programación
Opciones de programación
</button>
</div>
</div>
<mat-divider></mat-divider>
<div class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
@ -53,7 +56,7 @@
<div *ngFor="let client of clientData" class="client-item">
<div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}"
[ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above"
matTooltipClass="custom-tooltip">
@ -88,84 +91,166 @@
<mat-divider style="margin-top: 20px;"></mat-divider>
<div class="select-container">
<div class="deploy-container">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Tipo de imagen</mat-label>
<mat-select [(ngModel)]="imageType" (selectionChange)="onImageTypeSelected($event.value)">
<mat-option [value]="'monolithic'">Monolítica</mat-option>
<!--
<mat-option [value]="'git'">Git</mat-option>
-->
</mat-select>
</mat-form-field>
</div>
<div class="deploy-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" [disabled] = "!imageType" >
<mat-option *ngFor="let image of images" [value]="image">
<div class="unit-name"> {{ image.name }}</div>
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione método de deploy</mat-label>
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()">
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="partition-table-container">
<div *ngIf="showInstructions" class="instructions-box">
<mat-card class="instructions-card">
<mat-card-title>
Instrucciones generadas
<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
<!-- Sección: Configuración de imagen -->
<div class="form-section">
<div class="form-section-title">
<mat-icon>image</mat-icon>
Configuración de imagen
</div>
<div class="selector">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Tipo de imagen</mat-label>
<mat-select [(ngModel)]="imageType" (selectionChange)="onImageTypeSelected($event.value)">
<mat-option [value]="'monolithic'">Monolítica</mat-option>
<mat-option [value]="'git'">Git</mat-option>
</mat-select>
</mat-form-field>
</div>
<table mat-table [dataSource]="filteredPartitions" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group [(ngModel)]="selectedPartition" name="selectedPartition" (change)="validateImageSize()">
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<!-- Selectores Git (solo si imageType === 'git') -->
<div *ngIf="imageType === 'git'" class="filters-row git-gap">
<mat-form-field appearance="fill" style="width: 300px;">
<mat-label>Seleccionar Repositorio</mat-label>
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositoryChange($event.value)" [disabled]="loadingRepositories">
<mat-option *ngFor="let repo of repositories" [value]="repo">
{{ repo }}
</mat-option>
</mat-select>
<mat-icon matSuffix *ngIf="loadingRepositories">hourglass_empty</mat-icon>
</mat-form-field>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
{{ column.cell(image) }}
</td>
</ng-container>
<mat-form-field appearance="fill" style="width: 300px;">
<mat-label>Seleccionar Rama</mat-label>
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onGitBranchChange()" [disabled]="loadingBranches || !selectedRepository">
<mat-option *ngFor="let branch of branches" [value]="branch">
{{ branch }}
</mat-option>
</mat-select>
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
</mat-form-field>
</div>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- 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">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>Seleccionar</th>
<td mat-cell *matCellDef="let commit">
<mat-radio-group [(ngModel)]="selectedCommit" name="selectedCommit">
<mat-radio-button [value]="commit"></mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container matColumnDef="hexsha">
<th mat-header-cell *matHeaderCellDef>Commit ID</th>
<td mat-cell *matCellDef="let commit">
<code style="background-color: #f5f5f5; padding: 2px 4px; border-radius: 3px; font-family: monospace;">
{{ commit.hexsha }}
</code>
</td>
</ng-container>
<ng-container matColumnDef="message">
<th mat-header-cell *matHeaderCellDef>Mensaje</th>
<td mat-cell *matCellDef="let commit">{{ commit.message }}</td>
</ng-container>
<ng-container matColumnDef="committed_date">
<th mat-header-cell *matHeaderCellDef>Fecha</th>
<td mat-cell *matCellDef="let commit">{{ commit.committed_date * 1000 | date:'dd/MM/yyyy HH:mm:ss' }}</td>
</ng-container>
<ng-container matColumnDef="tags">
<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-list>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['select','hexsha','message','committed_date','tags']"></tr>
<tr mat-row *matRowDef="let row; columns: ['select','hexsha','message','committed_date','tags'];"></tr>
</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>
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()">
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" [disabled] = "!imageType" >
<mat-option *ngFor="let image of images" [value]="image">
<div class="unit-name"> {{ image.name }}</div>
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>
<mat-divider></mat-divider>
<div class="options-container">
<h3 *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')" class="input-group">Opciones multicast</h3>
<h3 *ngIf="isMethod('p2p')" class="input-group">Opciones torrent</h3>
<div *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')" class="input-group">
<!-- Sección: Selección de partición -->
<div class="form-section" id="partition-selection">
<div class="form-section-title">
<mat-icon>storage</mat-icon>
Selección de partición
</div>
<div class="partition-table-container">
<div *ngIf="showInstructions" class="instructions-box">
<mat-card class="instructions-card">
<mat-card-title>
Instrucciones generadas
<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
</div>
<table mat-table [dataSource]="filteredPartitions">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group [(ngModel)]="selectedPartition" name="selectedPartition">
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
{{ column.cell(image) }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</div>
<!-- Sección: Opciones avanzadas -->
<div class="form-section" id="advanced-options" *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct') || isMethod('p2p')">
<div class="form-section-title">
<mat-icon>settings</mat-icon>
<span *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">Opciones multicast</span>
<span *ngIf="isMethod('p2p')">Opciones torrent</span>
</div>
<div class="input-group" *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
<mat-form-field appearance="fill" class="input-field">
<mat-label>Puerto</mat-label>
<input matInput [(ngModel)]="mcastPort" name="mcastPort" type="number"
@ -207,7 +292,7 @@
</mat-form-field>
</div>
<div *ngIf="isMethod('p2p')" class="input-group">
<div class="input-group" *ngIf="isMethod('p2p')">
<mat-form-field appearance="fill" class="input-field">
<mat-label i18n="@@p2pModeLabel">Modo P2P</mat-label>
<mat-select [(ngModel)]="p2pMode" name="p2pMode"
@ -218,14 +303,22 @@
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="input-field">
<mat-label>Semilla</mat-label>
<mat-form-field appearance="fill" class="input-field" *ngIf="p2pMode === 'seeder'">
<mat-label>Semilla (minutos)</mat-label>
<input matInput [(ngModel)]="p2pTime" name="p2pTime" type="number"
[required]="isMethod('p2p')">
[required]="isMethod('p2p') && p2pMode === 'seeder'">
</mat-form-field>
</div>
</div>
</div>
<app-scroll-to-top
[threshold]="100"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -15,10 +15,12 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { ToastrModule, ToastrService } from 'ngx-toastr';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MatSelectModule } from '@angular/material/select';
import { MatExpansionModule } from "@angular/material/expansion";
import { MatIconModule } from "@angular/material/icon";
import { MatTooltipModule } from "@angular/material/tooltip";
import { LoadingComponent } from "../../../../../shared/loading/loading.component";
import { ScrollToTopComponent } from "../../../../../shared/scroll-to-top/scroll-to-top.component";
import { ConfigService } from '@services/config.service';
describe('DeployImageComponent', () => {
@ -32,7 +34,7 @@ describe('DeployImageComponent', () => {
};
await TestBed.configureTestingModule({
declarations: [DeployImageComponent, LoadingComponent],
declarations: [DeployImageComponent, LoadingComponent, ScrollToTopComponent],
imports: [
ReactiveFormsModule,
FormsModule,
@ -46,6 +48,8 @@ describe('DeployImageComponent', () => {
MatDividerModule,
MatRadioModule,
MatSelectModule,
MatIconModule,
MatTooltipModule,
BrowserAnimationsModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
@ -73,8 +77,7 @@ describe('DeployImageComponent', () => {
}
},
{ provide: ConfigService, useValue: mockConfigService }
],
schemas: [NO_ERRORS_SCHEMA]
]
})
.compileComponents();

View File

@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from "@angular/router";
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";
@Component({
selector: 'app-deploy-image',
@ -23,7 +24,7 @@ export class DeployImageComponent implements OnInit{
images: any[] = [];
selectedImage: any = null;
selectedMethod: string | null = null;
selectedPartition: any = null;
private _selectedPartition: any = null;
mcastIp: string = '';
mcastPort: Number = 0;
mcastMode: string = '';
@ -40,6 +41,8 @@ export class DeployImageComponent implements OnInit{
ogInstructions: string = '';
deployImage: boolean = true;
showInstructions: boolean = false;
loadingCommits: boolean = false;
selectedGitRepository: string = '';
protected p2pModeOptions = [
{ name: 'Leecher', value: 'leecher' },
@ -97,6 +100,15 @@ export class DeployImageComponent implements OnInit{
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
selection = new SelectionModel(true, []);
repositories: string[] = [];
loadingRepositories: boolean = false;
branches: string[] = [];
selectedBranch: string = '';
loadingBranches: boolean = false;
commits: any[] = [];
selectedCommit: any = null;
private initialGitLoad = true;
constructor(
private http: HttpClient,
private toastService: ToastrService,
@ -115,18 +127,11 @@ export class DeployImageComponent implements OnInit{
}
});
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
this.clientData.forEach((client: { selected: boolean; status: string}) => {
if (client.status === 'og-live') {
client.selected = true;
}
});
this.selectedClients = this.clientData.filter(
(client: { status: string }) => client.status === 'og-live'
);
this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
this.selectedModelClient = this.clientData.find(
(client: { status: string }) => client.status === 'og-live'
) || null;
this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
this.selectedModelClient = this.clientData.find((client: { selected: boolean; status: string}) => client.selected) || null;
if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient);
@ -141,7 +146,14 @@ export class DeployImageComponent implements OnInit{
onImageTypeSelected(event: any) {
this.imageType = event;
if (event === 'git') {
this.selectedMethod = null;
this.loadGitRepositories();
}
this.loadImages();
setTimeout(() => {
this.scrollToPartitionSection();
}, 200);
}
get runScriptTitle(): string {
@ -211,7 +223,7 @@ export class DeployImageComponent implements OnInit{
this.loadImages();
},
(error) => {
console.error('Error al cargar los datos completos del cliente:', error);
console.error('Error al cargar las particiones:', error);
}
);
} else {
@ -223,11 +235,7 @@ export class DeployImageComponent implements OnInit{
toggleSelectAll() {
this.allSelected = !this.allSelected;
this.clientData.forEach((client: { selected: boolean; status: string }) => {
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
}
loadImages() {
@ -265,90 +273,143 @@ export class DeployImageComponent implements OnInit{
}
}
this.errorMessage = "";
if (this.selectedMethod) {
setTimeout(() => {
this.scrollToPartitionSection();
}, 300);
}
return true;
}
save(): void {
this.loading = true;
if (!this.selectedClients.length) {
this.toastService.error('Debe seleccionar al menos un cliente');
this.loading = false;
return;
}
if (!this.selectedImage) {
if (!this.selectedImage && this.imageType !== 'git') {
this.toastService.error('Debe seleccionar una imagen');
this.loading = false;
return;
}
if (!this.selectedMethod) {
if (!this.selectedMethod && this.imageType !== 'git') {
this.toastService.error('Debe seleccionar un método');
this.loading = false;
return;
}
if (!this.selectedPartition) {
this.toastService.error('Debe seleccionar una partición');
this.loading = false;
return;
}
if (this.imageType === 'git' && !this.selectedCommit) {
this.toastService.error('Debe seleccionar un commit');
return;
}
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
});
this.toastService.info('Preparando petición de despliegue');
const payload = {
clients: this.selectedClients.map((client: any) => client.uuid),
method: this.selectedMethod,
// partition: this.selectedPartition['@id'],
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.p2pMode,
p2pTime: this.p2pTime,
mcastIp: this.mcastIp,
mcastPort: this.mcastPort,
mcastMode: this.mcastMode,
mcastSpeed: this.mcastSpeed,
maxTime: this.mcastMaxTime,
maxClients: this.mcastMaxClients,
};
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
this.loading = true;
let payload: any;
let url: string;
this.http.post(`${this.baseUrl}/image-image-repositories/${this.selectedImage.uuid}/deploy-image`, payload)
.subscribe({
next: (response) => {
this.toastService.success('Petición de despliegue enviada correctamente');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
this.toastService.error(error.error['hydra:description'], 'Se ha detectado un error en el despliegue de imágenes.', {
"closeButton": true,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-bottom-right",
"timeOut": 0,
"extendedTimeOut": 0,
"tapToDismiss": false
});
this.loading = false;
if (this.imageType === 'git') {
payload = {
type: 'git',
clients: this.selectedClients.map((client: any) => client.uuid),
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
repositoryName: this.selectedGitRepository,
branch: this.selectedBranch,
hexsha: this.selectedCommit.hexsha,
queue: result
};
url = `${this.baseUrl}/git-repositories/deploy-image`;
} else {
payload = {
clients: this.selectedClients.map((client: any) => client.uuid),
method: this.selectedMethod,
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.p2pMode,
p2pTime: this.p2pMode === 'seeder' ? this.p2pTime : null,
mcastIp: this.mcastIp,
mcastPort: this.mcastPort,
mcastMode: this.mcastMode,
mcastSpeed: this.mcastSpeed,
maxTime: this.mcastMaxTime,
maxClients: this.mcastMaxClients,
type: this.imageType,
queue: result
};
url = `${this.baseUrl}/image-image-repositories/${this.selectedImage.uuid}/deploy-image`;
}
});
this.http.post(url, payload)
.subscribe({
next: (response) => {
this.toastService.success('Petición de despliegue enviada correctamente');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
this.toastService.error(error.error['hydra:description'], 'Se ha detectado un error en el despliegue de imágenes.', {
"closeButton": true,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-bottom-right",
"timeOut": 0,
"extendedTimeOut": 0,
"tapToDismiss": false
});
this.loading = false;
}
});
}
});
}
isFormValid(): boolean {
if (!this.allSelected || !this.selectedModelClient || !this.selectedImage || !this.selectedMethod || !this.selectedPartition) {
if (!this.allSelected || !this.selectedModelClient || !this.selectedPartition) {
return false;
}
if (this.isMethod('udpcast') || this.isMethod('uftp') || this.isMethod('udpcast-direct')) {
if (!this.mcastPort || !this.mcastIp || !this.mcastMode || !this.mcastSpeed || !this.mcastMaxClients || !this.mcastMaxTime) {
if (this.imageType === 'git') {
if (!this.selectedCommit) {
return false;
}
}
if (this.isMethod('p2p')) {
if (!this.p2pMode || !this.p2pTime) {
return false;
if (this.imageType !== 'git' && !this.selectedMethod) {
return false;
}
if (this.imageType !== 'git') {
if (this.isMethod('udpcast') || this.isMethod('uftp') || this.isMethod('udpcast-direct')) {
if (!this.mcastPort || !this.mcastIp || !this.mcastMode || !this.mcastSpeed || !this.mcastMaxClients || !this.mcastMaxTime) {
return false;
}
}
if (this.isMethod('p2p')) {
if (!this.p2pMode) {
return false;
}
if (this.p2pMode === 'seeder' && !this.p2pTime) {
return false;
}
}
}
@ -372,14 +433,14 @@ export class DeployImageComponent implements OnInit{
method: this.selectedMethod,
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.selectedMethod === 'torrent' ? this.p2pMode : null,
p2pTime: this.selectedMethod === 'torrent' ? this.p2pTime : null,
mcastIp: this.selectedMethod === 'multicast' ? this.mcastIp : null,
mcastPort: this.selectedMethod === 'multicast' ? this.mcastPort : null,
mcastMode: this.selectedMethod === 'multicast' ? this.mcastMode : null,
mcastSpeed: this.selectedMethod === 'multicast' ? this.mcastSpeed : null,
maxTime: this.selectedMethod === 'multicast' ? this.mcastMaxTime : null,
maxClients: this.selectedMethod === 'multicast' ? this.mcastMaxClients : null,
p2pMode: this.selectedMethod === 'p2p' ? this.p2pMode : null,
p2pTime: this.selectedMethod === 'p2p' && this.p2pMode === 'seeder' ? this.p2pTime : null,
mcastIp: this.selectedMethod === 'udpcast' ? this.mcastIp : null,
mcastPort: this.selectedMethod === 'udpcast' ? this.mcastPort : null,
mcastMode: this.selectedMethod === 'udpcast' ? this.mcastMode : null,
mcastSpeed: this.selectedMethod === 'udpcast' ? this.mcastSpeed : null,
maxTime: this.selectedMethod === 'udpcast' ? this.mcastMaxTime : null,
maxClients: this.selectedMethod === 'udpcast' ? this.mcastMaxClients : null,
};
this.http.post(`${this.baseUrl}/command-task-scripts`, {
@ -400,37 +461,108 @@ export class DeployImageComponent implements OnInit{
}
generateOgInstructions() {
let script = '';
const disk = this.selectedPartition?.disk;
const partition = this.selectedPartition?.partition;
this.showInstructions = true;
this.ogInstructions = `og-deploy-image --image ${this.selectedImage.name} --partition ${this.selectedPartition.partitionNumber} --method ${this.selectedMethod}`;
}
let ip = this.selectedImage?.repository?.ip || 'REPO';
let imgName = this.selectedImage?.canonicalName || '';
let target = ` ${disk} ${partition}`;
let log = `ogEcho log session "[0] $MSG_SCRIPTS_TASK_START `;
if (this.deployImage) {
script = 'deployImage ';
} else {
script = 'updateCache ';
imgName += '.img';
target = '';
scrollToPartitionSection() {
const partitionSection = document.getElementById('partition-selection');
if (partitionSection) {
partitionSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
console.log('Scroll ejecutado');
}
}
script += `${ip} /${imgName}${target} ${this.selectedMethod}`;
log += `${script}"\n`;
script = log + script;
let params = '';
if (['udpcast', 'uftp', 'udpcast-direct'].includes(<string>this.selectedMethod)) {
params = `${this.mcastPort}:${this.mcastMode}:${this.mcastIp}:${this.mcastSpeed}M:${this.mcastMaxClients}:${this.mcastMaxTime}`;
} else if (this.selectedMethod === 'p2p') {
params = `${this.p2pMode}:${this.p2pTime}`;
scrollToAdvancedOptions() {
const advancedOptions = document.getElementById('advanced-options');
if (advancedOptions) {
advancedOptions.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
console.log('Scroll hacia opciones avanzadas ejecutado');
}
}
script += ` ${params}`;
get selectedPartition(): any {
return this._selectedPartition;
}
this.ogInstructions = script;
this.showInstructions = true
set selectedPartition(value: any) {
this._selectedPartition = value;
}
loadGitRepositories() {
this.loadingRepositories = true;
this.http.get<any>(`${this.baseUrl}/image-repositories/server/git/${this.selectedRepository?.uuid}/get-collection`).subscribe(
data => {
this.repositories = data.repositories || [];
this.loadingRepositories = false;
if (this.repositories.length > 0) {
this.selectedGitRepository = this.repositories[0];
this.loadGitBranches();
}
},
error => {
this.toastService.error('Error al cargar los repositorios git');
this.loadingRepositories = false;
}
);
}
onGitRepositoryChange(event: any) {
this.selectedGitRepository = event;
this.selectedBranch = '';
this.branches = [];
this.commits = [];
this.selectedCommit = null;
this.loadGitBranches();
}
loadGitBranches() {
if (!this.selectedGitRepository) return;
this.loadingBranches = true;
this.http.post<any>(`${this.baseUrl}/image-repositories/server/git/${this.selectedRepository?.uuid}/branches`, { repositoryName: this.selectedGitRepository }).subscribe(
data => {
this.branches = data.branches || [];
this.loadingBranches = false;
if (this.branches.length > 0) {
this.selectedBranch = this.branches[0];
this.loadGitCommits();
}
},
error => {
this.toastService.error('Error al cargar las ramas');
this.loadingBranches = false;
}
);
}
onGitBranchChange() {
this.selectedCommit = null;
this.commits = [];
this.loadGitCommits();
}
loadGitCommits() {
if (!this.selectedGitRepository || !this.selectedBranch) return;
this.loadingCommits = true;
this.http.post<any>(`${this.baseUrl}/image-repositories/server/git/${this.selectedRepository?.uuid}/commits`, {
repositoryName: this.selectedGitRepository,
branch: this.selectedBranch
}).subscribe(
data => {
this.commits = data.commits || [];
this.loadingCommits = false;
},
error => {
this.toastService.error('Error al cargar los commits');
this.loadingCommits = false;
}
);
}
}

View File

@ -5,9 +5,15 @@
<h2>
{{ 'partitionTitle' | translate }}
</h2>
<h4>
{{ runScriptTitle }}
</h4>
<div class="destination-info">
<div class="destination-badge">
<mat-icon class="destination-icon">cloud_download</mat-icon>
<div class="destination-content">
<span class="destination-label">Destino</span>
<span class="destination-value">{{ runScriptTitle }}</span>
</div>
</div>
</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>
@ -19,9 +25,9 @@
</button>
</div>
<div>
<button mat-stroked-button color="accent" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="openScheduleModal()">
<mat-icon>schedule</mat-icon> Opciones de programación
<div class="button-row">
<button class="action-button" color="accent" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="openScheduleModal()">
Opciones de programación
</button>
</div>
</div>
@ -46,7 +52,7 @@
<div *ngFor="let client of clientData" class="client-item">
<div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}"
[ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above"
matTooltipClass="custom-tooltip">
@ -77,18 +83,52 @@
</mat-expansion-panel>
</div>
<mat-divider style="margin-top: 20px;"></mat-divider>
<mat-dialog-content>
<div class="disk-select">
<mat-form-field appearance="fill">
<mat-label>Seleccionar disco</mat-label>
<mat-select [(ngModel)]="selectedDiskNumber">
<mat-option *ngFor="let disk of disks" [value]="disk.diskNumber">
Disco {{ disk.diskNumber }} ({{ (disk.totalDiskSize / 1024).toFixed(2) }} GB)
</mat-option>
</mat-select>
</mat-form-field>
<div class="disk-selector-card">
<div class="card-header">
<mat-icon class="card-icon">storage</mat-icon>
<div class="card-title">
<h3>Selección de Disco</h3>
<p>Elige el disco donde se realizarán las operaciones de particionado</p>
</div>
</div>
<div class="card-content">
<mat-form-field appearance="fill" class="disk-select-field">
<mat-label>Seleccionar disco</mat-label>
<mat-select [(ngModel)]="selectedDiskNumber" (selectionChange)="onDiskSelectionChange()">
<mat-option *ngFor="let disk of disks" [value]="disk.diskNumber">
<div class="disk-option">
<div class="disk-info">
<span class="disk-name">Disco {{ disk.diskNumber }}</span>
<span class="disk-size"> {{ (disk.totalDiskSize / 1024).toFixed(2) }} GB</span>
</div>
<div class="disk-details">
<span class="usage-percent"> {{ (disk.percentage || 0).toFixed(1) }}% usado</span>
</div>
</div>
</mat-option>
</mat-select>
<mat-hint>Selecciona el disco que deseas particionar</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">
<span class="message-title">No hay discos disponibles</span>
<span class="message-subtitle">Asegúrate de que el cliente modelo tenga discos configurados</span>
</div>
</div>
</div>
</div>
<div class="partition-assistant" *ngIf="selectedDisk">
@ -108,33 +148,66 @@
<div class="row-button">
<button class="action-button" [disabled]="partitionCode === 'MSDOS'" (click)="addPartition(selectedDisk.diskNumber)">Añadir partición</button>
<mat-chip *ngIf="selectedModelClient.firmwareType">
Firmware: {{ selectedModelClient.firmwareType }}
</mat-chip>
<mat-chip color="info" *ngIf="partitionCode">
Tabla de particiones: {{ partitionCode }}
</mat-chip>
<div class="info-badge" *ngIf="selectedModelClient.firmwareType">
<mat-icon class="info-icon">memory</mat-icon>
<div class="info-content">
<span class="info-label">Firmware</span>
<span class="info-value">{{ selectedModelClient.firmwareType }}</span>
</div>
</div>
<div class="info-badge" *ngIf="partitionCode">
<mat-icon class="info-icon">storage</mat-icon>
<div class="info-content">
<span class="info-label">Tabla de particiones</span>
<span class="info-value">{{ partitionCode }}</span>
</div>
</div>
</div>
<mat-divider style="padding: 10px;"></mat-divider>
<div class="disk-space-info-container">
<div class="disk-space-info" [ngClass]="selectedDisk.used < selectedDisk.totalDiskSize ? 'chip-free' : 'chip-full'">
Espacio usado: {{ selectedDisk.used | number:'1.2-2' }} MB
</div>
<div class="disk-space-info" [ngClass]="selectedDisk.used < selectedDisk.totalDiskSize ? 'chip-free' : 'chip-full'">
Espacio libre: {{ (selectedDisk.totalDiskSize - selectedDisk.used) | number:'1.2-2' }} MB
</div>
<div class="disk-space-info">
Espacio total: {{ selectedDisk.totalDiskSize | number:'1.2-2' }} MB
</div>
</div>
<div class="row">
<div class="form-container">
<table class="partition-table">
<div class="disk-space-info-container" id="disk-info">
<div class="disk-space-card">
<div class="space-info-item">
<mat-icon class="space-icon used-icon">storage</mat-icon>
<div class="space-details">
<span class="space-label">Espacio usado</span>
<span class="space-value">{{ selectedDisk.used | number:'1.2-2' }} MB</span>
</div>
</div>
<div class="space-info-item">
<mat-icon class="space-icon free-icon" [ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}">cloud_done</mat-icon>
<div class="space-details">
<span class="space-label">Espacio libre</span>
<span class="space-value" [ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}">{{ (selectedDisk.totalDiskSize - selectedDisk.used) | number:'1.2-2' }} MB</span>
</div>
</div>
<div class="space-info-item">
<mat-icon class="space-icon total-icon">dns</mat-icon>
<div class="space-details">
<span class="space-label">Espacio total</span>
<span class="space-value">{{ selectedDisk.totalDiskSize | number:'1.2-2' }} MB</span>
</div>
</div>
</div>
<div class="disk-usage-bar">
<div class="usage-bar-container">
<div class="usage-bar-fill"
[style.width.%]="(selectedDisk.used / selectedDisk.totalDiskSize) * 100"
[ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}"></div>
</div>
<span class="usage-percentage" [ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}">{{ ((selectedDisk.used / selectedDisk.totalDiskSize) * 100) | number:'1.1-1' }}% usado</span>
</div>
</div>
<table class="partition-table" id="partition-table">
<thead>
<tr>
<th>Partición</th>
@ -182,11 +255,29 @@
</tbody>
</table>
</div>
<div class="chart-container">
<ngx-charts-pie-chart [view]="view" [results]="selectedDisk.chartData" [doughnut]="true">
<div class="chart-container" *ngIf="selectedDisk">
<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>
</div>
</div>
</mat-dialog-content>
<app-scroll-to-top
[threshold]="200"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -7,6 +7,7 @@ import { FILESYSTEM_TYPES } from '../../../../../shared/constants/filesystem-typ
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";
interface Partition {
uuid?: string;
@ -46,7 +47,7 @@ export class PartitionAssistantComponent implements OnInit{
runScriptContext: any = null;
showInstructions = false;
view: [number, number] = [400, 300];
view: [number, number] = [300, 200];
showLegend = true;
showLabels = true;
allSelected = true;
@ -74,19 +75,11 @@ export class PartitionAssistantComponent implements OnInit{
}
});
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
this.clientData.forEach((client: { selected: boolean; status: string}) => {
if (client.status === 'og-live') {
client.selected = true;
}
});
this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
this.selectedClients = this.clientData.filter(
(client: { status: string }) => client.status === 'og-live'
);
this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
this.selectedModelClient = this.clientData.find(
(client: { status: string }) => client.status === 'og-live'
) || null;
this.selectedModelClient = this.clientData.find((client: { selected: boolean; status: string}) => client.selected) || null;
if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient);
@ -136,15 +129,18 @@ export class PartitionAssistantComponent implements OnInit{
toggleSelectAll() {
this.allSelected = !this.allSelected;
this.clientData.forEach((client: { selected: boolean; status: string }) => {
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
}
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;
}
const partitionsFromData = this.data.partitions;
this.originalPartitions = JSON.parse(JSON.stringify(partitionsFromData));
@ -320,56 +316,63 @@ export class PartitionAssistantComponent implements OnInit{
return;
}
this.loading = true;
const totalPartitionSize = this.selectedDisk.partitions
.filter((partition: any) => !partition.removed)
.reduce((sum: any, partition: any) => sum + partition.size, 0);
if (totalPartitionSize > this.selectedDisk.totalDiskSize) {
this.toastService.error('El tamaño total de las particiones en el disco seleccionado excede el tamaño total del disco.');
this.loading = false;
return;
}
const modifiedPartitions = this.selectedDisk.partitions.filter((partition: { removed: any; format: any; }) => !partition.removed || partition.format);
if (modifiedPartitions.length === 0) {
this.loading = false;
this.toastService.info('No hay cambios para guardar en el disco seleccionado.');
return;
}
const newPartitions = modifiedPartitions.map((partition: { partitionNumber: any; memoryUsage: any; size: any; partitionCode: any; filesystem: any; uuid: any; removed: any; format: any; }) => ({
diskNumber: this.selectedDisk.diskNumber,
partitionNumber: partition.partitionNumber,
memoryUsage: partition.memoryUsage,
size: partition.size,
partitionCode: partition.partitionCode,
filesystem: partition.filesystem,
uuid: partition.uuid,
removed: partition.removed || false,
format: partition.format || false,
}));
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
});
if (newPartitions.length > 0) {
const bulkPayload = {
partitions: newPartitions,
clients: this.selectedClients.map((client: any) => client.uuid),
};
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
this.loading = true;
const newPartitions = modifiedPartitions.map((partition: { partitionNumber: any; memoryUsage: any; size: any; partitionCode: any; filesystem: any; uuid: any; removed: any; format: any; }) => ({
diskNumber: this.selectedDisk.diskNumber,
partitionNumber: partition.partitionNumber,
memoryUsage: partition.memoryUsage,
size: partition.size,
partitionCode: partition.partitionCode,
filesystem: partition.filesystem,
uuid: partition.uuid,
removed: partition.removed || false,
format: partition.format || false,
}));
this.http.post(this.apiUrl, bulkPayload).subscribe(
(response) => {
this.toastService.success('Particiones creadas exitosamente para el disco seleccionado.');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
(error) => {
this.loading = false;
this.toastService.error('Error al crear las particiones.');
}
);
}
const bulkPayload = {
partitions: newPartitions,
clients: this.selectedClients.map((client: any) => client.uuid),
queue: result
};
this.http.post(this.apiUrl, bulkPayload).subscribe(
(response) => {
this.toastService.success('Particiones creadas exitosamente para el disco seleccionado.');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
(error) => {
this.loading = false;
this.toastService.error('Error al crear las particiones.');
}
);
}
});
}
@ -409,11 +412,20 @@ export class PartitionAssistantComponent implements OnInit{
generateChartData(partitions: Partition[]): any[] {
return partitions.map((partition) => ({
name: `Partición ${partition.partitionNumber}`,
value: partition.percentage,
color: partition.color
}));
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2'
];
return partitions
.filter(partition => !partition.removed)
.map((partition, index) => ({
name: `Partición ${partition.partitionNumber}`,
value: partition.size,
color: colors[index % colors.length],
partition: partition
}));
}
updateDiskChart(disk: any) {
@ -478,38 +490,50 @@ export class PartitionAssistantComponent implements OnInit{
}
generateInstructions(): void {
if (!this.selectedDisk || !this.selectedDisk.partitions) {
this.generatedInstructions = 'No hay particiones configuradas para generar instrucciones.';
return;
this.showInstructions = true;
this.generatedInstructions = `og-partition --disk ${this.selectedDiskNumber} --partitions ${this.selectedDisk.partitions.map((p: Partition) => `${p.partitionNumber}:${p.size}:${p.partitionCode}:${p.filesystem}:${p.format}`).join(',')}`;
}
onDiskSelected(diskNumber: number) {
this.selectedDiskNumber = diskNumber;
this.scrollToPartitionTable();
}
onDiskSelectionChange() {
if (this.selectedDiskNumber) {
this.scrollToPartitionTable();
}
}
const diskNumber = this.selectedDisk.diskNumber;
const partitionTable = this.partitionCode || 'MSDOS';
scrollToPartitionTable() {
// Pequeño delay para asegurar que el contenido se haya renderizado
setTimeout(() => {
const diskInfo = document.getElementById('disk-info');
if (diskInfo) {
diskInfo.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
}, 100);
}
let instructions = `ogCreatePartitionTable ${diskNumber} ${partitionTable}\n`;
instructions += `ogEcho log session "[0] $MSG_HELP_ogCreatePartitions"\n`;
instructions += `ogEcho session "[10] $MSG_HELP_ogUnmountAll ${diskNumber}"\n`;
instructions += `ogUnmountAll ${diskNumber} 2>/dev/null\n`;
instructions += `ogUnmountCache\n`;
instructions += `ogEcho session "[30] $MSG_HELP_ogUpdatePartitionTable ${diskNumber}"\n`;
instructions += `ogDeletePartitionTable ${diskNumber}\n`;
instructions += `ogUpdatePartitionTable ${diskNumber}\n`;
this.selectedDisk.partitions.forEach((partition: { removed: any; partitionNumber: any; partitionCode: any; filesystem: any; size: any; format: any; }, index: any) => {
if (partition.removed) return;
const partNumber = partition.partitionNumber;
const partType = partition.partitionCode;
const fs = partition.filesystem;
const size = partition.size;
const shouldFormat = partition.format ? 'yes' : 'no';
instructions += `ogCreatePartition ${diskNumber} ${partNumber} ${partType} ${fs} ${size}MB ${shouldFormat}\n`;
});
instructions += `ogExecAndLog command session ogListPartitions ${diskNumber}\n`;
this.generatedInstructions = instructions;
this.showInstructions = true
scrollToExecuteButton() {
console.log('scrollToExecuteButton llamado');
const executeButton = document.getElementById('execute-button');
console.log('Botón ejecutar encontrado:', executeButton);
if (executeButton) {
executeButton.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
console.log('Scroll hacia botón ejecutar completado');
} else {
console.error('No se encontró el botón execute-button');
}
}
}

View File

@ -1,4 +1,3 @@
.divider {
margin: 20px 0;
}
@ -102,10 +101,287 @@ table {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
padding: 24px 32px;
background: white;
border-radius: 12px;
margin-bottom: 20px;
}
.header-container-title {
flex-grow: 1;
text-align: left;
}
.header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
.header-container-title h4 {
margin: 0;
font-size: 16px;
opacity: 0.9;
font-weight: 400;
}
.button-row {
display: flex;
padding-right: 1em;
gap: 12px;
align-items: center;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
}
.action-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.select-container {
background: white !important;
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.form-section {
background: white !important;
border-radius: 16px;
padding: 20px !important;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #bbdefb;
}
.form-section-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
color: #2c3e50;
padding-bottom: 16px;
border-bottom: 2px solid #f8f9fa;
}
.form-section-title mat-icon {
color: #667eea;
font-size: 24px;
width: 24px;
height: 24px;
}
/* Badges y chips */
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
}
.info-badge {
display: inline-flex;
align-items: center;
background: #e8f5e8;
color: #2e7d32;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #c8e6c9;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
margin: 0 8px;
}
.info-badge:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.info-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #388e3c;
line-height: 1;
}
.info-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1b5e20;
}
/* Clientes y tarjetas */
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 20px;
}
.client-item {
position: relative;
}
.client-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
position: relative;
padding: 12px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.client-image {
width: 32px;
height: 32px;
margin-bottom: 8px;
}
.client-details {
margin-bottom: 12px;
}
.client-name {
font-size: 12px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 2px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.client-ip {
font-size: 10px;
color: #6c757d;
display: block;
margin-bottom: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-client {
background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%);
color: white;
border-color: #667eea;
}
.selected-client .client-name,
.selected-client .client-ip {
color: white;
}
::ng-deep .mat-expansion-panel {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
border-radius: 12px !important;
margin-bottom: 20px;
background: #f7fbff !important;
border: 1px solid #bbdefb !important;
}
::ng-deep .mat-expansion-panel-header {
padding: 20px 24px !important;
border-radius: 12px !important;
}
::ng-deep .mat-expansion-panel-header-title {
font-weight: 600 !important;
color: #2c3e50 !important;
}
::ng-deep .mat-expansion-panel-header-description {
color: #6c757d !important;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
@ -116,117 +392,11 @@ table {
margin-bottom: 30px;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-item {
position: relative;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
.client-details {
margin-top: 4px;
}
.client-name {
font-size: 0.9em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.button-row {
display: flex;
padding-right: 1em;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
::ng-deep .custom-tooltip {
white-space: pre-line !important;
max-width: 200px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
}
.selected-client {
background-color: #a0c2e5 !important;
color: white !important;
}
.button-row {
display: flex;
padding-right: 1em;
}
.disabled-client {
pointer-events: none;
opacity: 0.5;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.new-command-container {
display: flex;
flex-direction: column;
@ -261,15 +431,85 @@ table {
width: 100%;
}
.script-selector-card {
margin: 20px 20px;
padding: 16px;
/* Secciones del formulario */
.form-section {
background: white !important;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
border: 1px solid #bbdefb;
padding: 20px;
}
.form-section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.form-section-title mat-icon {
color: #2196f3;
}
.toggle-options {
display: flex;
justify-content: start;
margin: 16px 0;
gap: 10px;
margin-bottom: 20px;
}
.selected-toggle {
background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%) !important;
color: white !important;
}
mat-spinner {
margin: 20px auto;
display: block;
}
/* Estilo para hacer el backdrop no clickeable */
::ng-deep .non-clickable-backdrop {
pointer-events: none !important;
}
::ng-deep .action-chip {
margin: 8px !important;
padding: 12px 20px !important;
border-radius: px !important;
font-weight: 500 !important;
font-size: 14px !important;
transition: all 0.3s ease !important;
border: 2px solid transparent !important;
background: white !important;
color: #6c757d !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
min-height: 48px !important;
}
::ng-deep .action-chip:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
::ng-deep .action-chip.mat-mdc-chip-selected {
border-color: #667eea !important;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
}
::ng-deep .create-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%) !important;
color: white !important;
}
::ng-deep .update-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%) !important;
color: white !important;
}

View File

@ -10,12 +10,12 @@
</h4>
</div>
<div class="button-row">
<button class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript)" (click)="save()">Ejecutar</button>
<button class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || loading" (click)="save()">Ejecutar</button>
</div>
<div>
<button mat-stroked-button color="accent" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript)" (click)="openScheduleModal()">
<mat-icon>schedule</mat-icon> Opciones de programación
<div class="button-row">
<button color="accent" class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || loading" (click)="openScheduleModal()">
Opciones de programación
</button>
</div>
</div>
@ -40,8 +40,8 @@
<div class="clients-grid">
<div *ngFor="let client of clientData" class="client-item">
<div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above"
matTooltipClass="custom-tooltip">
@ -62,55 +62,69 @@
</mat-expansion-panel>
</div>
<mat-divider style="margin-top: 20px;"></mat-divider>
<div class="select-container">
<mat-card class="script-selector-card">
<mat-card-title>Seleccione el tipo de comando</mat-card-title>
<div class="toggle-options">
<mat-button-toggle-group [(ngModel)]="commandType" exclusive>
<mat-button-toggle value="new">
<mat-icon>edit</mat-icon> Nuevo Script
</mat-button-toggle>
<mat-button-toggle value="existing">
<mat-icon>storage</mat-icon> Script Guardado
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div *ngIf="commandType === 'new'" class="new-command-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Ingrese el script</mat-label>
<textarea matInput [(ngModel)]="newScript" rows="6" placeholder="Escriba su script aquí"></textarea>
</mat-form-field>
<button mat-flat-button color="primary" (click)="saveNewScript()">Guardar Script</button>
</div>
<div *ngIf="commandType === 'existing'">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">{{ script.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="selectedScript && commandType === 'existing'" class="script-container">
<div class="script-content">
<h3>Script:</h3>
<div class="script-preview" [innerHTML]="scriptContent"></div>
<div class="form-section">
<div class="form-section-title">
<mat-icon>code</mat-icon>
Configuración de script
</div>
<div class="script-params" *ngIf="parameterNames.length > 0 && selectedScript.parameters">
<h3>Ingrese los parámetros:</h3>
<div *ngFor="let paramName of parameterNames">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ paramName }}</mat-label>
<input matInput [ngModel]="parameters[paramName]" (ngModelChange)="onParamChange(paramName, $event)" placeholder="Valor para {{ paramName }}">
</mat-form-field>
<div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="commandType" required class="action-chip-listbox">
<mat-chip-option value="new" class="action-chip create-chip firmware-chip" (click)="commandType = 'new'">
<span>Nuevo Script</span>
</mat-chip-option>
<mat-chip-option value="existing" class="action-chip update-chip firmware-chip" (click)="commandType = 'existing'">
<span>Script Guardado</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<div *ngIf="commandType === 'new'" class="new-command-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Ingrese el script</mat-label>
<textarea matInput [(ngModel)]="newScript" rows="6" placeholder="Escriba su script aquí"></textarea>
</mat-form-field>
<button mat-flat-button color="primary" (click)="saveNewScript()">Guardar Script</button>
</div>
<div *ngIf="commandType === 'existing'">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">{{ script.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="selectedScript && commandType === 'existing'" class="script-container">
<div class="script-content">
<h3>Script:</h3>
<div class="script-preview" [innerHTML]="scriptContent"></div>
</div>
<div class="script-params" *ngIf="parameterNames.length > 0 && selectedScript.parameters">
<h3>Ingrese los parámetros:</h3>
<div *ngFor="let paramName of parameterNames">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ paramName }}</mat-label>
<input matInput [ngModel]="parameters[paramName]" (ngModelChange)="onParamChange(paramName, $event)" placeholder="Valor para {{ paramName }}">
</mat-form-field>
</div>
</div>
</div>
</div>
</mat-card>
</div>
<app-scroll-to-top
[threshold]="200"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RunScriptAssistantComponent } from './run-script-assistant.component';
import { DeployImageComponent } from "../deploy-image/deploy-image.component";
import { LoadingComponent } from "../../../../../shared/loading/loading.component";
import { ScrollToTopComponent } from "../../../../../shared/scroll-to-top/scroll-to-top.component";
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
@ -28,6 +29,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {MatIconModule} from "@angular/material/icon";
import {MatCardModule} from "@angular/material/card";
import {MatButtonToggleModule} from "@angular/material/button-toggle";
import { MatChipsModule } from "@angular/material/chips";
import { MatTooltipModule } from "@angular/material/tooltip";
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http);
@ -44,7 +47,7 @@ describe('RunScriptAssistantComponent', () => {
};
await TestBed.configureTestingModule({
declarations: [RunScriptAssistantComponent, DeployImageComponent, LoadingComponent],
declarations: [RunScriptAssistantComponent, DeployImageComponent, LoadingComponent, ScrollToTopComponent],
imports: [
ReactiveFormsModule,
FormsModule,
@ -63,6 +66,8 @@ describe('RunScriptAssistantComponent', () => {
MatIconModule,
MatCardModule,
MatButtonToggleModule,
MatChipsModule,
MatTooltipModule,
ToastrModule.forRoot(),
HttpClientTestingModule,
TranslateModule.forRoot({

View File

@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from "@angular/router";
import { SaveScriptComponent } from "./save-script/save-script.component";
import { MatDialog } from "@angular/material/dialog";
import {CreateTaskComponent} from "../../../../commands/commands-task/create-task/create-task.component";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
@Component({
selector: 'app-run-script-assistant',
@ -53,15 +54,9 @@ export class RunScriptAssistantComponent implements OnInit{
}
});
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
this.clientData.forEach((client: { selected: boolean; status: string}) => {
if (client.status === 'og-live') {
client.selected = true;
}
});
this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
this.selectedClients = this.clientData.filter(
(client: { status: string }) => client.status === 'og-live'
);
this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
this.loadScripts()
}
@ -122,18 +117,12 @@ export class RunScriptAssistantComponent implements OnInit{
}
updateSelectedClients() {
this.selectedClients = this.clientData.filter(
(client: { selected: boolean; status: string }) => client.selected && client.status === "og-live"
);
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 }) => {
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
}
getPartitionsTooltip(client: any): string {
@ -179,23 +168,33 @@ export class RunScriptAssistantComponent implements OnInit{
}
save(): void {
this.loading = true;
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true
});
this.http.post(`${this.baseUrl}/commands/run-script`, {
clients: this.selectedClients.map((client: any) => client.uuid),
script: this.commandType === 'existing' ? this.scriptContent : this.newScript,
}).subscribe(
response => {
this.toastService.success('Script ejecutado correctamente');
this.dataChange.emit();
this.router.navigate(['/commands-logs']);
},
error => {
this.toastService.error('Error al ejecutar el script');
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
this.loading = true;
this.http.post(`${this.baseUrl}/commands/run-script`, {
clients: this.selectedClients.map((client: any) => client.uuid),
script: this.commandType === 'existing' ? this.scriptContent : this.newScript,
queue: result
}).subscribe(
response => {
this.toastService.success('Script ejecutado correctamente');
this.dataChange.emit();
this.router.navigate(['/commands-logs']);
this.loading = false;
},
error => {
this.toastService.error('Error al ejecutar el script');
this.loading = false;
}
);
}
);
this.loading = false;
});
}
openScheduleModal(): void {

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,8 @@
<app-modal-overlay
[isVisible]="loading"
message="Cargando...">
</app-modal-overlay>
<div class="groups-container">
<!-- HEADER -->
<div class="header-container">
@ -96,8 +101,6 @@
</button>
</mat-form-field>
<mat-divider class="tree-mat-divider" style="padding-top: 10px;"></mat-divider>
<!-- Funcionalidad actualmente deshabilitada-->
<!-- <mat-form-field appearance="outline">
<mat-select (selectionChange)="loadSelectedFilter($event.value)" placeholder="Cargar filtro" disabled>
@ -120,48 +123,107 @@
<!-- Tree -->
<div class="tree-container" joyrideStep="treePanelStep" text="{{ 'treePanelStepText' | translate }}">
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}"
<div class="tree-header">
<h3 class="tree-title">
<mat-icon>account_tree</mat-icon>
{{ 'organizationalStructure' | translate }}
</h3>
<div class="tree-actions">
<button mat-icon-button (click)="expandAll()" matTooltip="{{ 'expandAll' | translate }}">
<mat-icon>unfold_more</mat-icon>
</button>
<button mat-icon-button (click)="collapseAll()" matTooltip="{{ 'collapseAll' | translate }}">
<mat-icon>unfold_less</mat-icon>
</button>
</div>
</div>
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl" class="modern-tree">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id, 'tree-node': true}"
*matTreeNodeDef="let node; when: hasChild" matTreeNodePadding (click)="onNodeClick($event, node)">
<button mat-icon-button matTreeNodeToggle [disabled]="!node.expandable"
[ngClass]="{'disabled-toggle': !node.expandable}">
<mat-icon>{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}</mat-icon>
</button>
<mat-icon class="node-icon {{ node.type }}">
{{
node.type === 'organizational-unit' ? 'apartment'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'lan'
: node.type === 'client' ? 'computer'
: 'group'
}}
</mat-icon>
<span>{{ node.name }}</span>
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)">
<mat-icon>more_vert</mat-icon>
</button>
<div class="node-content">
<button mat-icon-button matTreeNodeToggle [disabled]="!node.expandable"
[ngClass]="{'disabled-toggle': !node.expandable}" class="expand-button">
<mat-icon class="expand-icon">{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}</mat-icon>
</button>
<div class="node-info">
<div class="node-main">
<mat-icon class="node-icon {{ node.type }}" [matTooltip]="getNodeTypeTooltip(node.type)">
{{
node.type === 'organizational-unit' ? 'business'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'dns'
: node.type === 'client' ? 'computer'
: 'folder'
}}
</mat-icon>
<span class="node-name" [matTooltip]="node.name">{{ node.name }}</span>
</div>
<div class="node-details">
<ng-container *ngIf="node.type === 'client'">
<span class="node-ip">{{ node.ip }}</span>
</ng-container>
<ng-container *ngIf="node.children && node.children.length > 0">
<span class="node-count">{{ node.children.length }} {{ getNodeCountLabel(node.children.length) }}</span>
</ng-container>
</div>
</div>
<div class="node-actions">
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"
class="menu-button" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</mat-tree-node>
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}"
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id, 'tree-node': true}"
*matTreeNodeDef="let node; when: isLeafNode" matTreeNodePadding (click)="onNodeClick($event, node)">
<button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle"></button>
<mat-icon style="color: green;">
{{
node.type === 'organizational-unit' ? 'apartment'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'lan'
: node.type === 'client' ? 'computer'
: 'group'
}}
</mat-icon>
<span>{{ node.name }}</span>
<ng-container *ngIf="node.type === 'client'">
<span> - IP: {{ node.ip }}</span>
</ng-container>
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)">
<mat-icon>more_vert</mat-icon>
</button>
<div class="node-content">
<button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle expand-button">
<mat-icon class="expand-icon">chevron_right</mat-icon>
</button>
<div class="node-info">
<div class="node-main">
<mat-icon class="node-icon {{ node.type }}" [ngClass]="{'client-status': node.type === 'client'}"
[matTooltip]="getNodeTypeTooltip(node.type)">
{{
node.type === 'organizational-unit' ? 'business'
: node.type === 'classrooms-group' ? 'meeting_room'
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'dns'
: node.type === 'client' ? 'computer'
: 'folder'
}}
</mat-icon>
<span class="node-name" [matTooltip]="node.name">{{ node.name }}</span>
</div>
<div class="node-details">
<ng-container *ngIf="node.type === 'client'">
<span class="node-ip">{{ node.ip }}</span>
<span class="node-mac">{{ node.mac }}</span>
<span class="node-status" [ngClass]="'status-' + (node.status || 'off')">
{{ getStatusLabel(node.status) }}
</span>
</ng-container>
</div>
</div>
<div class="node-actions">
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"
class="menu-button" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</mat-tree-node>
</mat-tree>
</div>
@ -205,6 +267,10 @@
<mat-icon>storage</mat-icon>
<span>{{ 'partitions' | translate }}</span>
</button>
<button mat-menu-item (click)="openOUPendingTasks($event, selectedNode)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<app-execute-command [clientData]="selectedNode?.clients || []" [buttonType]="'menu-item'"
[buttonText]="'ejecutarComandos' | translate" [icon]="'terminal'"
[disabled]="!((selectedNode?.clients ?? []).length > 0)" [runScriptContext]="selectedNode?.name || ''"
@ -254,6 +320,45 @@
text="{{ 'clientsViewStepText' | translate }}">
<div *ngIf="hasClients; else noClientsTemplate">
<div class="stats-container" *ngIf="currentView === 'list'">
<div class="stat-card">
<div class="stat-icon">
<mat-icon>computer</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ totalStats.total }}</div>
<div class="stat-label">{{ 'totalClients' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon offline">
<mat-icon>wifi_off</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('off') }}</div>
<div class="stat-label">{{ 'offline' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon online">
<mat-icon>wifi</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('og-live') + getStatusCount('linux') + getStatusCount('windows') }}</div>
<div class="stat-label">{{ 'online' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon busy">
<mat-icon>hourglass_empty</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('busy') }}</div>
<div class="stat-label">{{ 'busy' | translate }}</div>
</div>
</div>
</div>
<!-- Cards view -->
<div *ngIf="currentView === 'card'">
<section class="cards-view">
@ -262,7 +367,7 @@
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
<div class="clients-grid">
<div *ngFor="let client of arrayClients" class="client-item">
<div *ngFor="let client of arrayClients" class="client-item" [ngClass]="'status-' + client.status">
<div class="client-card">
<mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(client)"
[checked]="selection.isSelected(client)">
@ -271,9 +376,9 @@
alt="Client Icon" class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name }}</span>
<span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
<span class="client-mac">{{ client.mac }}</span>
<div class="action-icons">
<app-execute-command [clientState]="client.status" [clientData]="[client]"
@ -309,6 +414,10 @@
<mat-icon>list_alt</mat-icon>
<span>{{ 'procedimientosCliente' | translate }}</span>
</button>
<button mat-menu-item (click)="openClientPendingTasks($event, client)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
@ -327,10 +436,24 @@
</div>
</div>
<!-- List view -->
<!-- List view mejorada -->
<div *ngIf="currentView === 'list'" class="list-view">
<div class="table-header">
<div class="table-info">
<span>{{ 'showingResults' | translate: { from: getPaginationFrom(), to: getPaginationTo(), total: getPaginationTotal() } }}</span>
</div>
<div class="table-actions">
<button mat-icon-button (click)="refreshClientData()" matTooltip="{{ 'refresh' | translate }}">
<mat-icon>refresh</mat-icon>
</button>
<button mat-icon-button (click)="exportToCSV()" matTooltip="{{ 'exportCSV' | translate }}">
<mat-icon>download</mat-icon>
</button>
</div>
</div>
<section class="clients-table" tabindex="0">
<table mat-table matSort [dataSource]="selectedClients">
<table mat-table [dataSource]="selectedClients" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
@ -345,7 +468,14 @@
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'status' | translate }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'status' | translate }}</span>
<button mat-icon-button (click)="sortColumn('status')" class="sort-button">
<mat-icon>{{ getSortIcon('status') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<div class="client-status-container">
@ -359,72 +489,101 @@
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'name' | translate }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'name' | translate }}</span>
<button mat-icon-button (click)="sortColumn('name')" class="sort-button">
<mat-icon>{{ getSortIcon('name') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<p>{{ client.name }}</p>
<div class="client-cell">
<span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="ip">
<th mat-header-cell *matHeaderCellDef mat-sort-header>IP </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>IP</span>
<button mat-icon-button (click)="sortColumn('ip')" class="sort-button">
<mat-icon>{{ getSortIcon('ip') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500">
<div style="display: flex; flex-direction: column;">
<span>{{ client.ip }}</span>
<span style="font-size: 0.75rem; color: gray;">{{ client.mac }}</span>
<div class="client-cell">
<span class="client-ip">{{ client.ip }}</span>
<span class="client-mac">{{ client.mac }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="firmwareType">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'firmwareType' | translate }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'firmwareType' | translate }}</span>
<button mat-icon-button (click)="sortColumn('firmwareType')" class="sort-button">
<mat-icon>{{ getSortIcon('firmwareType') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client">
<mat-chip *ngIf="client.firmwareType">
<mat-chip *ngIf="client.firmwareType" class="firmware-chip">
{{ client.firmwareType }}
</mat-chip>
</td>
</ng-container>
<ng-container matColumnDef="oglive">
<th mat-header-cell *matHeaderCellDef mat-sort-header> OG Live </th>
<th mat-header-cell *matHeaderCellDef> OG Live </th>
<td mat-cell *matCellDef="let client">
<div style="display: flex; flex-direction: column;">
<span>{{ client.ogLive?.kernel }} </span>
<span style="font-size: 0.75rem; color: gray;"> {{ client.ogLive?.date | date }}</span>
<div class="oglive-cell">
<span class="oglive-kernel">{{ client.ogLive?.kernel }}</span>
<span class="oglive-date">{{ client.ogLive?.date | date }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="maintenace">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'maintenance' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'maintenance' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.maintenance }} </td>
</ng-container>
<ng-container matColumnDef="subnet">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'subnet' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'subnet' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.subnet }} </td>
</ng-container>
<ng-container matColumnDef="pxeTemplate">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'pxeTemplate' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.pxeTemplate?.name }} </td>
<th mat-header-cell *matHeaderCellDef> {{ 'pxeTemplate' | translate }} </th>
<td mat-cell *matCellDef="let client" class="truncate-cell-medium" [matTooltip]="client.pxeTemplate?.name"> {{ client.pxeTemplate?.name }} </td>
</ng-container>
<ng-container matColumnDef="parentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'parent' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.parentName }} </td>
<th mat-header-cell *matHeaderCellDef> {{ 'parent' | translate }} </th>
<td mat-cell *matCellDef="let client" class="truncate-cell-medium" [matTooltip]="client.parentName"> {{ client.parentName }} </td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'actions' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'actions' | translate }} </th>
<td mat-cell *matCellDef="let client">
<button
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
mat-icon-button [matMenuTriggerFor]="clientMenu" color="primary">
<mat-icon>more_vert</mat-icon>
</button>
<app-execute-command [clientState]="client.status" [clientData]="[client]" [buttonType]="'icon'"
[icon]="'terminal'"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
[runScriptContext]="getRunScriptContext([client])">
</app-execute-command>
<div class="action-buttons">
<button
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
mat-icon-button [matMenuTriggerFor]="clientMenu" color="primary" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
<app-execute-command [clientState]="client.status" [clientData]="[client]" [buttonType]="'icon'"
[icon]="'terminal'"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
[runScriptContext]="getRunScriptContext([client])" matTooltip="{{ 'executeCommand' | translate }}">
</app-execute-command>
<button mat-icon-button color="primary" (click)="onShowClientDetail($event, client)" matTooltip="{{ 'viewDetails' | translate }}">
<mat-icon>visibility</mat-icon>
</button>
</div>
<mat-menu #clientMenu="matMenu">
<button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>edit</mat-icon>
@ -442,6 +601,10 @@
<mat-icon>list_alt</mat-icon>
<span>{{ 'procedimientosCliente' | translate }}</span>
</button>
<button mat-menu-item (click)="openClientPendingTasks($event, client)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span>
@ -449,9 +612,11 @@
</mat-menu>
</td>
</ng-container>
<tr mat-header-row style="background-color: #f3f3f3;"
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;" class="mat-row"
[ngClass]="'status-' + row.status"
[class.selected-row]="selectedClient?.uuid === row.uuid"
(click)="selectClient(row)"></tr>
</table>
</section>
<mat-paginator class="list-paginator" [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page"

View File

@ -27,6 +27,7 @@ import { TreeNode } from './model/model';
import { LoadingComponent } from '../../shared/loading/loading.component';
import { ExecuteCommandComponent } from '../commands/main-commands/execute-command/execute-command.component';
import { ConfigService } from '@services/config.service';
import { ModalOverlayComponent } from '../../shared/modal-overlay/modal-overlay.component';
describe('GroupsComponent', () => {
let component: GroupsComponent;
@ -39,7 +40,7 @@ describe('GroupsComponent', () => {
};
await TestBed.configureTestingModule({
declarations: [GroupsComponent, ExecuteCommandComponent, LoadingComponent],
declarations: [GroupsComponent, ExecuteCommandComponent, LoadingComponent, ModalOverlayComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),

View File

@ -15,7 +15,6 @@ import { ShowOrganizationalUnitComponent } from './shared/organizational-units/s
import { LegendComponent } from './shared/legend/legend.component';
import { DeleteModalComponent } from '../../shared/delete_modal/delete-modal/delete-modal.component';
import { ClassroomViewDialogComponent } from './shared/classroom-view/classroom-view-modal';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { PageEvent } from '@angular/material/paginator';
import { CreateMultipleClientComponent } from "./shared/clients/create-multiple-client/create-multiple-client.component";
@ -31,6 +30,7 @@ import { PartitionTypeOrganizatorComponent } from './shared/partition-type-organ
import { ClientTaskLogsComponent } from '../task-logs/client-task-logs/client-task-logs.component';
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';
enum NodeType {
OrganizationalUnit = 'organizational-unit',
@ -79,6 +79,29 @@ export class GroupsComponent implements OnInit, OnDestroy {
arrayClients: any[] = [];
filters: { [key: string]: string } = {};
private clientFilterSubject = new Subject<string>();
loading = false;
// Nuevas propiedades para funcionalidades mejoradas
selectedClient: any = null;
sortBy: string = 'name';
sortDirection: 'asc' | 'desc' = 'asc';
currentSortColumn: string = 'name';
// Estadísticas totales
totalStats: {
total: number;
off: number;
online: number;
busy: number;
} = {
total: 0,
off: 0,
online: 0,
busy: 0
};
// Tipos de firmware disponibles
firmwareTypes: string[] = [];
protected status = [
{ value: 'off', name: 'Apagado' },
@ -95,16 +118,6 @@ export class GroupsComponent implements OnInit, OnDestroy {
displayedColumns: string[] = ['select', 'status', 'ip', 'firmwareType', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
private _sort!: MatSort;
@ViewChild(MatSort)
set matSort(ms: MatSort) {
this._sort = ms;
if (this.selectedClients) {
this.selectedClients.sort = this._sort;
}
}
private subscriptions: Subscription = new Subscription();
constructor(
@ -404,8 +417,14 @@ export class GroupsComponent implements OnInit, OnDestroy {
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = []): void {
const params = new HttpParams({ fromObject: this.filters });
// Agregar parámetros de ordenamiento al backend
let backendParams = { ...this.filters };
if (this.sortBy) {
backendParams['order[' + this.sortBy + ']'] = this.sortDirection;
}
this.isLoadingClients = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params }).subscribe({
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
next: (response: any) => {
this.selectedClients.data = response['hydra:member'];
if (this.selectedNode) {
@ -423,6 +442,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selection.select(client);
}
});
// Calcular estadísticas después de cargar los clientes
this.calculateLocalStats();
},
error: () => {
this.isLoadingClients = false;
@ -438,25 +460,35 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
addOU(event: MouseEvent, parent: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation();
const dialogRef = this.dialog.open(ManageOrganizationalUnitComponent, {
data: { parent },
width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((newUnit) => {
if (newUnit) {
this.refreshData(newUnit.uuid);
}
this.loading = false;
});
}
addClient(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(ManageClientComponent, {
data: { organizationalUnit: targetNode },
width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
@ -469,17 +501,22 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.refreshData(parentNode.uuid);
}
}
this.loading = false;
});
}
addMultipleClients(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(CreateMultipleClientComponent, {
data: { organizationalUnit: targetNode },
width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
@ -494,29 +531,33 @@ export class GroupsComponent implements OnInit, OnDestroy {
console.error('No se encontró el nodo padre después de la creación masiva.');
}
}
this.loading = false;
});
}
onEditNode(event: MouseEvent, node: TreeNode | null): void {
event.stopPropagation();
this.loading = true;
const uuid = node ? this.extractUuid(node['@id']) : null;
if (!uuid) return;
const dialogRef = node?.type !== NodeType.Client
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px' });
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
this.refreshData(node?.id);
}
this.menuTriggers.forEach(trigger => trigger.closeMenu());
this.loading = false;
});
}
onDeleteClick(event: MouseEvent, entity: TreeNode | Client | null): void {
event.stopPropagation();
this.loading = true;
if (!entity) return;
const uuid = entity['@id'] ? this.extractUuid(entity['@id']) : null;
@ -533,6 +574,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
if (result === true) {
this.deleteEntityorClient(uuid, type);
}
this.loading = false;
});
}
@ -570,16 +612,18 @@ export class GroupsComponent implements OnInit, OnDestroy {
onEditClick(event: MouseEvent, type: string, uuid: string): void {
event.stopPropagation();
this.loading = true;
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
const dialogRef = type !== NodeType.Client
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px' });
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
dialogRef.afterClosed().subscribe((result) => {
if (result?.success) {
this.refreshData(this.selectedNode?.id, selectedClientsBeforeEdit);
}
this.menuTriggers.forEach(trigger => trigger.closeMenu());
this.loading = false;
});
}
@ -592,6 +636,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.dialog.open(ClassroomViewDialogComponent, {
width: '90vw',
data: { clients: response['hydra:member'] },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
},
(error) => {
@ -603,35 +650,46 @@ export class GroupsComponent implements OnInit, OnDestroy {
executeCommand(command: Command, selectedNode: TreeNode | null): void {
this.loading = true;
if (!selectedNode) {
this.toastr.error('No hay un nodo seleccionado.');
return;
} else {
this.toastr.success(`Ejecutando comando: ${command.name} en ${selectedNode.name}`);
}
this.loading = false;
}
onShowClientDetail(event: MouseEvent, client: Client): void {
event.stopPropagation();
this.dialog.open(ClientDetailsComponent, {
this.loading = true;
const dialogRef = this.dialog.open(ClientDetailsComponent, {
width: '70vw',
height: '90vh',
data: { clientData: client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
onShowDetailsClick(event: MouseEvent, data: TreeNode | null): void {
event.stopPropagation();
this.loading = true;
if (data && data.type !== NodeType.Client) {
this.dialog.open(ShowOrganizationalUnitComponent, { data: { data }, width: '800px' });
this.dialog.open(ShowOrganizationalUnitComponent, { data: { data }, width: '800px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
} else {
if (data) {
this.router.navigate(['clients', this.extractUuid(data['@id'])], { state: { clientData: data } });
}
}
this.loading = false;
}
@ -873,6 +931,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
const dialogRef = this.dialog.open(ChangeParentComponent, {
data: { clients: this.selection.selected },
width: '700px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
@ -883,11 +944,269 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
openClientTaskLogs(event: MouseEvent, client: Client): void {
this.loading = true;
event.stopPropagation();
this.dialog.open(ClientTaskLogsComponent, {
const dialogRef = this.dialog.open(ClientTaskLogsComponent, {
width: '1200px',
data: { client }
data: { client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
openClientPendingTasks(event: MouseEvent, client: Client): void {
this.loading = true;
event.stopPropagation();
const dialogRef = this.dialog.open(ClientPendingTasksComponent, {
width: '1200px',
data: { client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
openOUPendingTasks(event: MouseEvent, node: any): void {
event.stopPropagation();
this.loading = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=1&itemsPerPage=10000`).subscribe({
next: (response) => {
const allClients = response['hydra:member'] || [];
if (allClients.length === 0) {
this.toastr.warning('Esta unidad organizativa no tiene clientes');
return;
}
const ouClientData = {
name: node.name,
id: node.id,
uuid: node.uuid,
type: 'organizational-unit',
clients: allClients
};
const dialogRef = this.dialog.open(ClientPendingTasksComponent, {
width: '1200px',
data: { client: ouClientData, isOrganizationalUnit: true },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
},
error: (error) => {
console.error('Error al obtener los clientes de la unidad organizativa:', error);
this.toastr.error('Error al cargar los clientes de la unidad organizativa');
this.loading = false;
}
});
}
// Métodos para paginación
getPaginationFrom(): number {
return (this.page * this.itemsPerPage) + 1;
}
getPaginationTo(): number {
return Math.min((this.page + 1) * this.itemsPerPage, this.length);
}
getPaginationTotal(): number {
return this.length;
}
refreshClientData(): void {
this.fetchClientsForNode(this.selectedNode);
this.toastr.success('Datos actualizados', 'Éxito');
}
exportToCSV(): void {
const headers = ['Nombre', 'IP', 'MAC', 'Estado', 'Firmware', 'Subnet', 'Parent'];
const csvData = this.arrayClients.map(client => [
client.name,
client.ip || '',
client.mac || '',
client.status || '',
client.firmwareType || '',
client.subnet || '',
client.parentName || ''
]);
const csvContent = [headers, ...csvData]
.map(row => row.map(cell => `"${cell}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `clients_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.toastr.success('Archivo CSV exportado correctamente', 'Éxito');
}
private calculateLocalStats(): void {
const clients = this.arrayClients;
this.totalStats = {
total: clients.length,
off: clients.filter(client => client.status === 'off').length,
online: clients.filter(client => ['og-live', 'linux', 'windows', 'linux-session', 'windows-session'].includes(client.status)).length,
busy: clients.filter(client => client.status === 'busy').length
};
// Actualizar tipos de firmware disponibles
this.firmwareTypes = [...new Set(clients.map(client => client.firmwareType).filter(Boolean))];
}
// Métodos para funcionalidades mejoradas
selectClient(client: any): void {
this.selectedClient = client;
}
sortColumn(columnDef: string): void {
if (this.currentSortColumn === columnDef) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.currentSortColumn = columnDef;
this.sortDirection = 'asc';
}
this.sortBy = columnDef;
this.onSortChange();
}
getSortIcon(columnDef: string): string {
if (this.currentSortColumn !== columnDef) {
return 'unfold_more';
}
return this.sortDirection === 'asc' ? 'expand_less' : 'expand_more';
}
onSortChange(): void {
// Hacer nueva llamada al backend con el ordenamiento actualizado
this.fetchClientsForNode(this.selectedNode);
}
getStatusCount(status: string): number {
switch(status) {
case 'off':
return this.totalStats.off;
case 'online':
return this.totalStats.online;
case 'busy':
return this.totalStats.busy;
default:
return this.arrayClients.filter(client => client.status === status).length;
}
}
// Métodos para el árbol mejorado
expandAll(): void {
this.treeControl.expandAll();
}
collapseAll(): void {
this.treeControl.collapseAll();
}
getNodeTypeTooltip(nodeType: string): string {
switch (nodeType) {
case 'organizational-unit':
return 'Unidad Organizacional - Estructura principal de la organización';
case 'classrooms-group':
return 'Grupo de Aulas - Conjunto de aulas relacionadas';
case 'classroom':
return 'Aula - Espacio físico con equipos informáticos';
case 'clients-group':
return 'Grupo de Equipos - Conjunto de equipos informáticos';
case 'client':
return 'Equipo Informático - Computadora o dispositivo individual';
case 'group':
return 'Grupo - Agrupación lógica de elementos';
default:
return 'Elemento del árbol organizacional';
}
}
getNodeCountLabel(count: number): string {
if (count === 1) return 'elemento';
return 'elementos';
}
getStatusLabel(status: string): string {
const statusLabels: { [key: string]: string } = {
'off': 'Apagado',
'og-live': 'OG Live',
'linux': 'Linux',
'linux-session': 'Linux Session',
'windows': 'Windows',
'windows-session': 'Windows Session',
'busy': 'Ocupado',
'mac': 'Mac',
'disconnected': 'Desconectado',
'initializing': 'Inicializando'
};
return statusLabels[status] || status;
}
// Funciones para el dashboard de estadísticas
getTotalOrganizationalUnits(): number {
let total = 0;
const countOrganizationalUnits = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.type === 'organizational-unit') {
total += 1;
}
if (node.children) {
countOrganizationalUnits(node.children);
}
});
};
countOrganizationalUnits(this.originalTreeData);
return total;
}
getTotalClassrooms(): number {
let total = 0;
const countClassrooms = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.type === 'classroom') {
total += 1;
}
if (node.children) {
countClassrooms(node.children);
}
});
};
countClassrooms(this.originalTreeData);
return total;
}
// Función para actualizar estadísticas cuando cambian los datos
private updateDashboardStats(): void {
// Las estadísticas de equipos ya se calculan en calculateLocalStats()
// Solo necesitamos asegurar que se actualicen cuando cambian los datos
this.calculateLocalStats();
}
}

View File

@ -35,7 +35,7 @@ export class ClientViewComponent {
{ property: 'Fecha de creación', value: this.data.client.createdAt },
{ property: 'NTP', value: this.data.client.organizationalUnit?.networkSettings?.ntp || '' },
{ property: 'Modo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pMode || '' },
{ property: 'Tiempo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' },
...(this.data.client.organizationalUnit?.networkSettings?.p2pMode === 'seeder' ? [{ property: 'Tiempo p2p (minutos)', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' }] : []),
{ property: 'IP multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastIp || '' },
{ property: 'Modo multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastMode || '' },
{ property: 'Puerto multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastPort || '' },
@ -51,7 +51,7 @@ export class ClientViewComponent {
{ property: 'Router', value: this.data.client.organizationalUnit?.networkSettings?.router || '' },
{ property: 'NTP', value: this.data.client.organizationalUnit?.networkSettings?.ntp || '' },
{ property: 'Modo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pMode || '' },
{ property: 'Tiempo p2p', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' },
...(this.data.client.organizationalUnit?.networkSettings?.p2pMode === 'seeder' ? [{ property: 'Tiempo p2p (minutos)', value: this.data.client.organizationalUnit?.networkSettings?.p2pTime || '' }] : []),
{ property: 'IP multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastIp || '' },
{ property: 'Modo multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastMode || '' },
{ property: 'Puerto multicast', value: this.data.client.organizationalUnit?.networkSettings?.mcastPort || '' },

View File

@ -134,8 +134,8 @@
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'p2pTimeLabel' | translate }}</mat-label>
<mat-form-field class="form-field" *ngIf="networkSettingsFormGroup.get('p2pMode')?.value === 'seeder'">
<mat-label>{{ 'p2pTimeLabel' | translate }} (minutos)</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
<mat-form-field class="form-field">

View File

@ -271,6 +271,13 @@ export class ManageOrganizationalUnitComponent implements OnInit {
onSubmit() {
if (this.generalFormGroup.valid && this.additionalInfoFormGroup.valid && this.networkSettingsFormGroup.valid) {
const parentValue = this.generalFormGroup.value.parent;
// Preparar networkSettings con lógica condicional para p2pTime
const networkSettings = { ...this.networkSettingsFormGroup.value };
if (networkSettings.p2pMode !== 'seeder') {
networkSettings.p2pTime = null;
}
const formData = {
name: this.generalFormGroup.value.name,
excludeParentChanges: this.generalFormGroup.value.excludeParentChanges,
@ -279,7 +286,7 @@ export class ManageOrganizationalUnitComponent implements OnInit {
comments: this.additionalInfoFormGroup.value.comments,
remoteCalendar: this.generalFormGroup.value.remoteCalendar,
type: this.generalFormGroup.value.type,
networkSettings: this.networkSettingsFormGroup.value,
networkSettings: networkSettings,
location: this.classroomInfoFormGroup.value.location,
projector: this.classroomInfoFormGroup.value.projector,
board: this.classroomInfoFormGroup.value.board,

View File

@ -92,7 +92,7 @@ export class ShowOrganizationalUnitComponent implements OnInit {
{ property: 'Router', value: this.ou.networkSettings.router },
{ property: 'NTP', value: this.ou.networkSettings.ntp },
{ property: 'Modo P2P', value: this.ou.networkSettings.p2pMode },
{ property: 'Tiempo P2P', value: this.ou.networkSettings.p2pTime },
...(this.ou.networkSettings.p2pMode === 'seeder' ? [{ property: 'Tiempo P2P (minutos)', value: this.ou.networkSettings.p2pTime }] : []),
{ property: 'Mcast IP', value: this.ou.networkSettings.mcastIp },
{ property: 'Mcast Speed', value: this.ou.networkSettings.mcastSpeed },
{ property: 'Mcast Port', value: this.ou.networkSettings.mcastPort },

View File

@ -65,7 +65,7 @@ export class LoginComponent {
this.openSnackBar(false, 'Bienvenido ' + this.auth.username);
this.router.navigateByUrl('/groups');
this.dialog.open(GlobalStatusComponent, {
width: '45vw',
width: '65vw',
height: '80vh',
});
}

View File

@ -51,7 +51,6 @@
(click)="openShowMonoliticImagesDialog(repository)">
{{ 'monolithicImage' | translate }}
</button>
<!--
<button
class="action-button"
joyrideStep="gitImageStep"
@ -60,8 +59,7 @@
[disabled]="!isGitModuleInstalled"
(click)="openShowGitImagesDialog(repository)">
{{ 'gitImage' | translate }}
</button
-->
</button>
</ng-container>
</td>
</ng-container>

View File

@ -10,7 +10,7 @@ import { Router } from '@angular/router';
import { ConfigService } from '@services/config.service';
import {Subnet} from "../ogdhcp/og-dhcp-subnets.component";
import {ShowMonoliticImagesComponent} from "./show-monolitic-images/show-monolitic-images.component";
import {ShowGitImagesComponent} from "./show-git-images/show-git-images.component";
import {ShowGitCommitsComponent} from "./show-git-images/show-git-images.component";
import {ManageRepositoryComponent} from "./manage-repository/manage-repository.component";
@Component({
@ -146,7 +146,7 @@ export class RepositoriesComponent implements OnInit {
}
openShowGitImagesDialog(repository: Subnet) {
const dialogRef = this.dialog.open(ShowGitImagesComponent, {
const dialogRef = this.dialog.open(ShowGitCommitsComponent, {
width: '85vw',
height: '85vh',
maxWidth: '85vw',

View File

@ -98,3 +98,94 @@ table {
gap: 1em;
padding: 1.5em;
}
/* Estilos específicos para commits */
.repository-selector-container {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.branch-selector-container {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.commit-id {
font-family: 'Courier New', monospace;
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.commit-message {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-stats {
font-size: 0.85em;
color: #666;
}
.commit-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.commit-tags .mat-chip {
font-size: 0.8em;
height: 24px;
}
.no-tags {
color: #999;
font-style: italic;
font-size: 0.9em;
}
.mat-mdc-table {
border-radius: 8px;
overflow: hidden;
}
.mat-mdc-header-cell {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
.mat-mdc-row:hover {
background-color: #f8f9fa;
}
.action-buttons {
display: flex;
gap: 4px;
justify-content: center;
}
.action-buttons .mat-mdc-icon-button {
width: 32px;
height: 32px;
line-height: 32px;
}
.filters-row {
display: flex;
gap: 20px;
align-items: flex-end;
margin-bottom: 20px;
}
.repository-selector-container,
.branch-selector-container {
margin-bottom: 0 !important;
}

View File

@ -6,96 +6,96 @@
<button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon>
</button>
<h2>Gestionar imágenes git en {{data.repositoryName}}</h2>
<h2>Commits de Git en {{data.repositoryName}}</h2>
</div>
<div class="images-button-row">
<button class="action-button" (click)="openImageInfoDialog()">Ver Información</button>
<button class="action-button" disabled (click)="syncRepository()">Sincronizar base de datos</button>
<button class="action-button" disabled (click)="importImage()">
{{ 'importImageButton' | translate }}
</button>
</div>
</div>
<div class="search-container">
<mat-form-field appearance="fill" class="search-string" joyrideStep="searchImagesField"
text="Busca una imagen por nombre. Pulsa 'enter' para iniciar la búsqueda.">
<mat-label>{{ 'searchLabel' | translate }}</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['name']" (keyup.enter)="loadData()"
i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<mat-hint>{{ 'searchHint' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="search-boolean">
<mat-label i18n="@@searchLabel">Estado</mat-label>
<mat-select [(ngModel)]="filters['status']" (selectionChange)="loadData()" placeholder="Seleccionar opción">
<mat-option [value]="'failed'">Fallido</mat-option>
<mat-option [value]="'pending'">Pendiente</mat-option>
<mat-option [value]="'in-progress'">Transfiriendo</mat-option>
<mat-option [value]="'success'">Creado con éxito</mat-option>
<mat-option [value]="'transferring'">En progreso</mat-option>
<mat-option [value]="'trash'">Papelera</mat-option>
<mat-option [value]="'aux-files-pending'">Creando archivos auxiliares</mat-option>
<div class="filters-row">
<mat-form-field appearance="fill" style="width: 300px;">
<mat-label>Seleccionar Repositorio</mat-label>
<mat-select [(ngModel)]="selectedRepository" (selectionChange)="onRepositoryChange()" [disabled]="loadingRepositories">
<mat-option *ngFor="let repo of repositories" [value]="repo">
{{ repo }}
</mat-option>
</mat-select>
<mat-icon matSuffix *ngIf="loadingRepositories">hourglass_empty</mat-icon>
</mat-form-field>
<mat-form-field appearance="fill" style="width: 300px;">
<mat-label>Seleccionar Rama</mat-label>
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onBranchChange()" [disabled]="loadingBranches || !selectedRepository">
<mat-option *ngFor="let branch of branches" [value]="branch">
{{ branch }}
</mat-option>
</mat-select>
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
</mat-form-field>
</div>
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8" joyrideStep="imagesTable"
text="Esta tabla muestra las imágenes disponibles.">
<div class="search-container">
<mat-form-field appearance="fill" class="search-string" joyrideStep="searchCommitsField"
text="Busca un commit por mensaje. Pulsa 'enter' para iniciar la búsqueda.">
<mat-label>Buscar commits</mat-label>
<input matInput placeholder="Búsqueda por mensaje" [(ngModel)]="filters['message']" (keyup.enter)="loadData()"
i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<mat-hint>Buscar por mensaje del commit</mat-hint>
</mat-form-field>
</div>
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8" joyrideStep="commitsTable"
text="Esta tabla muestra los commits disponibles.">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef === 'isGlobal'">
<mat-chip>
{{ image.isGlobal ? 'Sí' : 'No' }}
</mat-chip>
<td mat-cell *matCellDef="let commit">
<ng-container *ngIf="column.columnDef === 'hexsha'">
<code class="commit-id">
{{ column.cell(commit) }}
</code>
</ng-container>
<ng-container *ngIf="column.columnDef === 'status'">
<mat-chip [ngClass]="{
'chip-failed': image.status === 'failed',
'chip-success': image.status === 'success',
'chip-pending': image.status === 'pending',
'chip-in-progress': image.status === 'in-progress',
'chip-transferring': image.status === 'transferring',
}">
{{ getStatusLabel(image[column.columnDef]) }}
</mat-chip>
<ng-container *ngIf="column.columnDef === 'message'">
<div class="commit-message">
{{ column.cell(commit) }}
</div>
</ng-container>
<ng-container
*ngIf="column.columnDef !== 'remotePc' && column.columnDef !== 'status' && column.columnDef !== 'isGlobal'">
{{ column.cell(image) }}
<ng-container *ngIf="column.columnDef === 'stats_total'">
<div class="commit-stats">
{{ column.cell(commit) }}
</div>
</ng-container>
<ng-container *ngIf="column.columnDef === 'tags'">
<div class="commit-tags">
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected>
{{ tag }}
</mat-chip>
<span *ngIf="!commit.tags || commit.tags.length === 0" class="no-tags">
Sin tags
</span>
</div>
</ng-container>
<ng-container *ngIf="column.columnDef !== 'hexsha' && column.columnDef !== 'message' && column.columnDef !== 'stats_total' && column.columnDef !== 'tags'">
{{ column.cell(commit) }}
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: center;">Acciones</th>
<td mat-cell *matCellDef="let image" style="text-align: center;">
<button mat-icon-button color="primary" (click)="goToPage(image)">
<mat-icon>visibility</mat-icon>
</button>
<td mat-cell *matCellDef="let commit" style="text-align: center;">
<div class="action-buttons">
<a [href]="'http://localhost:3100/oggit/' + selectedRepository + '/commit/' + commit.hexsha" target="_blank" matTooltip="Ver commit en Git">
<button mat-icon-button color="primary">
<mat-icon>open_in_new</mat-icon>
</button>
</a>
<button mat-icon-button color="primary" (click)="toggleAction(image, 'edit')">
<mat-icon i18n="@@deleteElementTooltip">edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="toggleAction(image, 'delete-trash')">
<mat-icon i18n="@@deleteElementTooltip">delete</mat-icon>
</button>
<button mat-icon-button [matMenuTriggerFor]="menu">
<mat-icon>menu</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item
(click)="toggleAction(image, 'show-tags')">Ver tags</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'"
(click)="toggleAction(image, 'show-branches')">Ver ramas</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'"
(click)="toggleAction(image, 'transfer')">Transferir imagen</button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'"
(click)="toggleAction(image, 'transfer-global')">Transferir imagen globalmente </button>
<button mat-menu-item [disabled]="!image.imageFullsum || image.status !== 'success'"
(click)="toggleAction(image, 'backup')">Realizar backup </button>
</mat-menu>
<button mat-icon-button color="primary" (click)="toggleAction(commit, 'view-details')" matTooltip="Ver detalles">
<mat-icon>info</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>

View File

@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShowGitImagesComponent } from './show-git-images.component';
import { ShowGitCommitsComponent } from './show-git-images.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ToastrModule } from 'ngx-toastr';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
@ -17,9 +17,9 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
describe('ShowGitImagesComponent', () => {
let component: ShowGitImagesComponent;
let fixture: ComponentFixture<ShowGitImagesComponent>;
describe('ShowGitCommitsComponent', () => {
let component: ShowGitCommitsComponent;
let fixture: ComponentFixture<ShowGitCommitsComponent>;
beforeEach(async () => {
const mockConfigService = {
@ -27,7 +27,7 @@ describe('ShowGitImagesComponent', () => {
};
await TestBed.configureTestingModule({
declarations: [ShowGitImagesComponent, LoadingComponent],
declarations: [ShowGitCommitsComponent, LoadingComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),
@ -52,7 +52,7 @@ describe('ShowGitImagesComponent', () => {
})
.compileComponents();
fixture = TestBed.createComponent(ShowGitImagesComponent);
fixture = TestBed.createComponent(ShowGitCommitsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -16,12 +16,12 @@ import {BackupImageComponent} from "../backup-image/backup-image.component";
import {EditImageComponent} from "../edit-image/edit-image.component";
@Component({
selector: 'app-show-git-images',
selector: 'app-show-git-commits',
templateUrl: './show-git-images.component.html',
styleUrl: './show-git-images.component.css'
})
export class ShowGitImagesComponent implements OnInit{
baseUrl: string;
export class ShowGitCommitsComponent implements OnInit{
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();
length: number = 0;
@ -32,46 +32,50 @@ baseUrl: string;
alertMessage: string | null = null;
repository: any = {};
datePipe: DatePipe = new DatePipe('es-ES');
branches: string[] = [];
selectedBranch: string = '';
loadingBranches: boolean = false;
repositories: string[] = [];
selectedRepository: string = '';
loadingRepositories: boolean = false;
private initialLoad = true;
columns = [
{
columnDef: 'id',
header: 'Id',
cell: (image: any) => `${image.id}`
columnDef: 'hexsha',
header: 'Commit ID',
cell: (commit: any) => commit.hexsha
},
{
columnDef: 'repositoryName',
header: 'Nombre del repositorio',
cell: (image: any) => image.image?.name
columnDef: 'message',
header: 'Mensaje del commit',
cell: (commit: any) => commit.message
},
{
columnDef: 'name',
header: 'Nombre de imagen',
cell: (image: any) => image.name
columnDef: 'committed_date',
header: 'Fecha del commit',
cell: (commit: any) => `${this.datePipe.transform(commit.committed_date * 1000, 'dd/MM/yyyy hh:mm:ss')}`
},
{
columnDef: 'tag',
header: 'Tag',
cell: (image: any) => image.tag
columnDef: 'size',
header: 'Tamaño',
cell: (commit: any) => `${commit.size} bytes`
},
{
columnDef: 'isGlobal',
header: 'Imagen global',
cell: (image: any) => image.image?.isGlobal
columnDef: 'stats_total',
header: 'Estadísticas',
cell: (commit: any) => {
if (commit.stats_total) {
return `+${commit.stats_total.insertions} -${commit.stats_total.deletions} (${commit.stats_total.files} archivos)`;
}
return '';
}
},
{
columnDef: 'status',
header: 'Estado',
cell: (image: any) => image.status
},
{
columnDef: 'description',
header: 'Descripción',
cell: (image: any) => image.description
},
{
columnDef: 'createdAt',
header: 'Fecha de creación',
cell: (image: any) => `${this.datePipe.transform(image.createdAt, 'dd/MM/yyyy hh:mm:ss')}`
columnDef: 'tags',
header: 'Tags',
cell: (commit: any) => commit.tags?.length > 0 ? commit.tags.join(', ') : 'Sin tags'
}
];
displayedColumns = [...this.columns.map(column => column.columnDef), 'actions'];
@ -83,214 +87,129 @@ baseUrl: string;
private joyrideService: JoyrideService,
private configService: ConfigService,
private router: Router,
public dialogRef: MatDialogRef<ShowGitImagesComponent>,
public dialogRef: MatDialogRef<ShowGitCommitsComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
this.apiUrl = `${this.baseUrl}/git-image-repositories`;
this.apiUrl = `${this.baseUrl}/image-repositories/server/git/${this.data.repositoryUuid}`;
}
ngOnInit(): void {
if (this.data) {
this.loadData();
this.loadRepositories();
}
}
loadRepositories(): void {
this.loadingRepositories = true;
this.http.get<any>(`${this.apiUrl}/get-collection`).subscribe(
data => {
this.repositories = data.repositories || [];
this.loadingRepositories = false;
if (this.repositories.length > 0) {
this.selectedRepository = this.repositories[0];
this.loadBranches();
}
},
error => {
console.error('Error fetching repositories', error);
this.toastService.error('Error al cargar los repositorios');
this.loadingRepositories = false;
}
);
}
onRepositoryChange(): void {
this.selectedBranch = '';
this.branches = [];
this.page = 0;
this.loadBranches();
}
loadBranches(): void {
if (!this.selectedRepository) {
return;
}
this.loadingBranches = true;
this.http.post<any>(`${this.apiUrl}/branches`, { repositoryName: this.selectedRepository }).subscribe(
data => {
this.branches = data.branches || [];
this.loadingBranches = false;
if (this.branches.length > 0) {
this.selectedBranch = this.branches[0];
this.loadData();
if (this.initialLoad) {
this.initialLoad = false;
}
}
},
error => {
console.error('Error fetching branches', error);
this.toastService.error('Error al cargar las ramas del repositorio');
this.loadingBranches = false;
}
);
}
onBranchChange(): void {
this.page = 0;
this.loadData();
}
loadData(): void {
if (!this.selectedBranch || !this.selectedRepository) {
return;
}
this.loading = true;
this.http.get<any>(`${this.apiUrl}?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}&repository.id=${this.data.repositoryId}`, { params: this.filters }).subscribe(
const payload = {
repositoryName: this.selectedRepository,
branch: this.selectedBranch
};
this.http.post<any>(`${this.apiUrl}/commits`, payload).subscribe(
data => {
this.dataSource.data = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.dataSource.data = data.commits || [];
this.length = data.commits?.length || 0;
this.loading = false;
},
error => {
console.error('Error fetching image repositories', error);
console.error('Error fetching commits', error);
this.toastService.error('Error al cargar los commits');
this.loading = false;
}
)
}
getStatusLabel(status: string): string {
switch (status) {
case 'pending':
return 'Pendiente';
case 'in-progress':
return 'En progreso';
case 'aux-files-pending':
return 'Archivos auxiliares pendientes';
case 'success':
return 'Creado con éxito';
case 'trash':
return 'Papelera temporal';
case 'failed':
return 'Fallido';
case 'transferring':
return 'Transfiriendo';
default:
return 'Estado desconocido';
}
);
}
onPageChange(event: any): void {
this.page = event.pageIndex;
this.itemsPerPage = event.pageSize;
this.length = event.length;
this.loadData();
}
loadImageAlert(image: any): Observable<any> {
return this.http.get<any>(`${this.apiUrl}/server/${image.uuid}/get`, {});
}
importImage(): void {
this.dialog.open(ImportImageComponent, {
width: '600px',
data: {
repositoryUuid: this.data.repositoryUuid,
name: this.data.repositoryName
}
}).afterClosed().subscribe((result) => {
if (result) {
this.loadData();
}
});
}
toggleAction(image: any, action:string): void {
toggleAction(commit: any, action: string): void {
switch (action) {
case 'delete-trash':
if (!image.imageFullsum) {
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '400px',
data: { name: image.name },
});
dialogRef.afterClosed().subscribe((result) => {
this.http.delete(`${this.baseUrl}${image['@id']}`).subscribe({
next: () => {
this.toastService.success('Image deleted successfully');
this.loadData()
},
error: (error) => {
this.toastService.error('Error deleting image');
}
});
});
} else {
this.http.post(`${this.baseUrl}/image-image-repositories/server/${image.uuid}/delete-trash`,
{ repository: `/image-repositories/${this.data.repositoryUuid}` })
.subscribe({
next: () => {
this.toastService.success('Petición de eliminación de la papelera temporal enviada');
this.loadData()
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
}
break;
case 'delete-permanent':
this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: image.name },
}).afterClosed().subscribe((result) => {
if (result) {
this.http.post(`${this.baseUrl}/image-image-repositories/server/${image.uuid}/delete-permanent`, {}).subscribe({
next: () => {
this.toastService.success('Petición de eliminación de la papelera temporal enviada');
this.loadData()
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
}
});
break;
case 'edit':
this.dialog.open(EditImageComponent, {
width: '600px',
case 'view-details':
this.dialog.open(ServerInfoDialogComponent, {
width: '800px',
data: {
image: image,
}
}).afterClosed().subscribe((result) => {
if (result) {
this.loadData();
title: 'Detalles del Commit',
content: {
'Commit ID': commit.hexsha,
'Mensaje': commit.message,
'Fecha': this.datePipe.transform(commit.committed_date * 1000, 'dd/MM/yyyy hh:mm:ss'),
'Tamaño': `${commit.size} bytes`,
'Archivos modificados': commit.stats_total?.files || 0,
'Líneas añadidas': commit.stats_total?.insertions || 0,
'Líneas eliminadas': commit.stats_total?.deletions || 0,
'Tags': commit.tags?.join(', ') || 'Sin tags'
}
}
});
break;
case 'recover':
this.http.post(`${this.baseUrl}/image-image-repositories/server/${image.uuid}/recover`, {}).subscribe({
next: () => {
this.toastService.success('Petición de recuperación de la imagen enviada');
this.loadData()
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
break;
case 'transfer':
this.http.get(`${this.baseUrl}${image.image['@id']}`).subscribe({
next: (response) => {
this.dialog.open(ExportImageComponent, {
width: '600px',
data: {
image: response,
imageImageRepository: image
}
});
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
break;
case 'transfer-global':
this.http.post<any>(`${this.baseUrl}/image-image-repositories/server/${image.uuid}/transfer-global`, {
}).subscribe({
next: (response) => {
this.toastService.success('Petición de exportación de imagen realizada correctamente');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: error => {
this.loading = false;
this.toastService.error('Error en la petición de exportación de imagen');
}
});
break;
case 'backup':
this.http.get(`${this.baseUrl}${image.image['@id']}`).subscribe({
next: (response) => {
this.dialog.open(BackupImageComponent, {
width: '600px',
data: {
image: response,
imageImageRepository: image
}
});
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
});
break;
case 'show-tags':
this.http.get(`${this.baseUrl}/git-image-repositories/server/${image.uuid}/get-tags`, {}).subscribe({
next: (response) => {
this.dialog.open(ServerInfoDialogComponent, {
width: '800px',
data: {
repositories: response
}
});
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
case 'copy-commit-id':
navigator.clipboard.writeText(commit.hexsha).then(() => {
this.toastService.success('Commit ID copiado al portapapeles');
});
break;
default:
@ -302,55 +221,40 @@ baseUrl: string;
iniciarTour(): void {
this.joyrideService.startTour({
steps: [
'imagesTitleStep',
'addImageButton',
'searchImagesField',
'imagesTable',
'commitsTitleStep',
'repositorySelector',
'branchSelector',
'searchCommitsField',
'commitsTable',
'actionsHeader',
'editImageButton',
'deleteImageButton',
'imagesPagination'
'viewCommitButton',
'copyCommitButton',
'commitsPagination'
],
showPrevButton: true,
themeColor: '#3f51b5'
});
}
loadAlert(): Observable<any> {
return this.http.post<any>(`${this.baseUrl}/image-repositories/server/git/${this.data.repositoryUuid}/get-collection`, {});
}
syncRepository() {
this.http.post(`${this.baseUrl}/image-repositories/server/git/${this.data.repositoryUuid}/sync`, {})
.subscribe(response => {
this.toastService.success('Sincronización completada');
this.loadData()
}, error => {
console.error('Error al sincronizar', error);
this.toastService.error('Error al sincronizar');
});
}
openImageInfoDialog() {
this.loadAlert().subscribe(
response => {
this.alertMessage = response.repositories;
this.dialog.open(ServerInfoDialogComponent, {
width: '800px',
data: {
repositories: this.alertMessage
}
});
},
error => {
console.error('Error al cargar la información del alert', error);
this.dialog.open(ServerInfoDialogComponent, {
width: '800px',
data: {
title: 'Información del Repositorio',
content: {
'Nombre del repositorio': this.selectedRepository || 'No seleccionado',
'UUID del repositorio': this.data.repositoryUuid,
'Rama seleccionada': this.selectedBranch || 'No seleccionada',
'Total de repositorios': this.repositories.length,
'Total de ramas': this.branches.length,
'Total de commits': this.length
}
}
);
});
}
goToPage( image: any) {
window.location.href = `http://192.168.68.20:3000/oggit/${image.image.name}`;
goToPage(commit: any) {
window.open(`http://localhost:3100/oggit/${this.selectedRepository}/commit/${commit.hexsha}`, '_blank');
}
onNoClick(): void {

View File

@ -0,0 +1,143 @@
.modal-content {
max-height: 85vh;
overflow-y: auto;
padding: 1rem;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
.header-container-title {
flex-grow: 1;
text-align: left;
margin-left: 1em;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.header-actions button {
display: flex;
align-items: center;
gap: 5px;
}
.calendar-button-row {
display: flex;
gap: 15px;
}
.lists-container {
padding: 16px;
}
.imagesLists-container {
flex: 1;
}
.card.unidad-card {
height: 100%;
box-sizing: border-box;
}
table {
width: 100%;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
margin: 1.5rem 0rem 0.5rem 0rem;
box-sizing: border-box;
}
.search-boolean {
flex: 1;
padding: 5px;
}
.search-select {
flex: 2;
padding: 5px;
}
.search-date {
flex: 1;
padding: 5px;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.2);
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
}
/* Ajuste para el botón de cancelar en la barra de progreso */
.progress-container .cancel-button {
margin-left: auto;
flex-shrink: 0;
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
}
.chip-failed {
background-color: #e87979 !important;
color: white;
}
.chip-success {
background-color: #46c446 !important;
color: white;
}
.chip-pending {
background-color: #bebdbd !important;
color: black;
}
.chip-in-progress {
background-color: #f5a623 !important;
color: white;
}
.status-progress-flex {
display: flex;
align-items: center;
gap: 8px;
}
button.cancel-button {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
}
.cancel-button {
color: red;
background-color: transparent;
border: none;
padding: 0;
}
.cancel-button mat-icon {
color: red;
}

View File

@ -0,0 +1,158 @@
<div class="modal-content">
<div class="header-container">
<div class="header-container-title">
<h2 *ngIf="!data.isOrganizationalUnit">{{ 'colaAcciones' | translate }}</h2>
<h2 *ngIf="data.isOrganizationalUnit">{{ 'colaAcciones' | translate }} - {{ data.client?.name }}</h2>
</div>
<div class="header-actions">
<button mat-raised-button color="warn" (click)="clearAllActions()"
[disabled]="traces.length === 0 || loading"
matTooltip="Cancelar todas las acciones mostradas">
<mat-icon>clear_all</mat-icon>
{{ 'limpiarAcciones' | translate }}
</button>
</div>
</div>
<div class="search-container" joyrideStep="filtersStep" text="{{ 'filtersStepText' | translate }}">
<mat-form-field appearance="fill" class="search-select">
<mat-label>{{ 'commandSelectStepText' | translate }}</mat-label>
<mat-select (selectionChange)="onOptionCommandSelected($event.value)" #commandSearchInput>
<mat-option *ngFor="let command of filteredCommands2" [value]="command">
{{ translateCommand(command.name) }}
</mat-option>
</mat-select>
<button *ngIf="commandSearchInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearCommandFilter($event, commandSearchInput)">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="fill" class="search-date">
<mat-label>Desde</mat-label>
<input matInput [matDatepicker]="fromPicker" [(ngModel)]="filters['startDate']"
(dateChange)="onDateFilterChange()" [max]="today">
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="search-date">
<mat-label>Hasta</mat-label>
<input matInput [matDatepicker]="toPicker" [(ngModel)]="filters['endDate']" (dateChange)="onDateFilterChange()"
[max]="today">
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
</div>
<app-loading [isLoading]="loading"></app-loading>
<div *ngIf="!loading">
<table mat-table [dataSource]="traces" class="mat-elevation-z8">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let trace">
<ng-container [ngSwitch]="column.columnDef">
<ng-container *ngSwitchCase="'status'">
<ng-container *ngIf="trace.status === 'in-progress' && trace.progress; else statusChip">
<div class="progress-container">
<span>{{trace.progress}}%</span>
<button mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="Cancelar tarea">
<mat-icon>cancel</mat-icon>
</button>
</div>
</ng-container>
<ng-template #statusChip>
<div class="status-progress-flex" joyrideStep="tracesProgressStep"
text="{{ 'tracesProgressStepText' | translate }}">
<mat-chip [ngClass]="{
'chip-pending': trace.status === 'pending',
}">
{{
trace.status === 'pending' ? 'Pendiente' :
trace.status
}}
</mat-chip>
<button *ngIf="trace.status === 'in-progress'" mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="Cancelar tarea">
<mat-icon>cancel</mat-icon>
</button>
</div>
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'command'">
<div style="display: flex; flex-direction: column;">
<span>{{ translateCommand(trace.command) }}</span>
<span style="font-size: 0.75rem; color: gray;">{{ trace.jobId }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'client'">
<div style="display: flex; flex-direction: column;">
<span>{{ trace.client?.name }}</span>
<span style="font-size: 0.75rem; color: gray;">{{ trace.client?.ip }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'executedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.executedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'finishedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.finishedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
</div>
</ng-container>
<ng-container *ngSwitchDefault>
{{ column.cell(trace) }}
</ng-container>
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="information">
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'informationLabel' | translate }}</th>
<td mat-cell *matCellDef="let trace" style="text-align: center;" joyrideStep="tracesInfoStep"
text="{{ 'tracesInfoStepText' | translate }}">
<button mat-icon-button color="primary" [disabled]="!trace.input" (click)="openInputModal(trace.input)">
<mat-icon>
<span class="material-symbols-outlined">
mode_comment
</span>
</mat-icon>
</button>
<button mat-icon-button color="primary" [disabled]="!trace.output" (click)="openOutputModal(trace.output)">
<mat-icon>
<span class="material-symbols-outlined">
info
</span>
</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
<div class="paginator-container" joyrideStep="paginationStep" text="{{ 'paginationStepText' | translate }}">
<mat-paginator [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page" [pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)">
</mat-paginator>
</div>
</div>
<div mat-dialog-actions align="end" style="padding: 16px 24px;">
<button class="ordinary-button" (click)="close()">{{ 'closeButton' | translate }}</button>
</div>

View File

@ -0,0 +1,328 @@
import { Component, OnInit, Inject, ChangeDetectorRef } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { DatePipe } from '@angular/common';
import { ConfigService } from '@services/config.service';
import { ToastrService } from 'ngx-toastr';
import { TranslationService } from '@services/translation.service';
import { OutputDialogComponent } from '../output-dialog/output-dialog.component';
import { InputDialogComponent } from '../input-dialog/input-dialog.component';
import { JoyrideService } from 'ngx-joyride';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
import { COMMAND_TYPES } from 'src/app/shared/constants/command-types';
import { DeleteModalComponent } from 'src/app/shared/delete_modal/delete-modal/delete-modal.component';
@Component({
selector: 'app-client-pending-tasks',
templateUrl: './client-pending-tasks.component.html',
styleUrls: ['./client-pending-tasks.component.css']
})
export class ClientPendingTasksComponent implements OnInit {
baseUrl: string;
mercureUrl: string;
traces: any[] = [];
length: number = 0;
itemsPerPage: number = 20;
page: number = 0;
loading: boolean = true;
pageSizeOptions: number[] = [10, 20, 30, 50];
datePipe: DatePipe = new DatePipe('es-ES');
filters: { [key: string]: any } = {};
filteredCommands!: Observable<any[]>;
commandControl = new FormControl();
columns = [
{
columnDef: 'id',
header: 'ID',
cell: (trace: any) => `${trace.id}`,
},
{
columnDef: 'command',
header: 'Comando',
cell: (trace: any) => trace.command
},
{
columnDef: 'status',
header: 'Estado',
cell: (trace: any) => trace.status
},
{
columnDef: 'executedAt',
header: 'Ejecución',
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
},
{
columnDef: 'finishedAt',
header: 'Finalización',
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
},
];
displayedColumns = [...this.columns.map(column => column.columnDef), 'information'];
filteredCommands2 = Object.keys(COMMAND_TYPES).map(key => ({
name: key,
value: key,
label: COMMAND_TYPES[key]
}));
today = new Date();
constructor(
private http: HttpClient,
@Inject(MAT_DIALOG_DATA) public data: { client: any, isOrganizationalUnit?: boolean },
private joyrideService: JoyrideService,
private dialog: MatDialog,
private cdr: ChangeDetectorRef,
private configService: ConfigService,
private toastService: ToastrService,
private translationService: TranslationService,
public dialogRef: MatDialogRef<ClientPendingTasksComponent>
) {
this.baseUrl = this.configService.apiUrl;
this.mercureUrl = this.configService.mercureUrl;
}
ngOnInit(): void {
// Si es una unidad organizativa, agregar columna de cliente
if (this.data.isOrganizationalUnit) {
this.columns.splice(2, 0, {
columnDef: 'client',
header: 'Cliente',
cell: (trace: any) => trace.client?.name || 'N/A'
});
this.displayedColumns = [...this.columns.map(column => column.columnDef), 'information'];
}
this.loadTraces();
const eventSource = new EventSource(`${this.mercureUrl}?topic=`
+ encodeURIComponent(`traces`));
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data && data['@id']) {
this.updateTracesStatus(data['@id'], data.status);
}
}
}
private updateTracesStatus(clientUuid: string, newStatus: string): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === clientUuid);
if (traceIndex !== -1) {
const updatedTraces = [...this.traces];
updatedTraces[traceIndex] = {
...updatedTraces[traceIndex],
status: newStatus
};
this.traces = updatedTraces;
this.cdr.detectChanges();
}
}
loadTraces(): void {
this.loading = true;
let params = new HttpParams()
.set('status', 'pending')
.set('page', (this.page + 1).toString())
.set('itemsPerPage', this.itemsPerPage.toString());
// Si es una unidad organizativa, obtener las trazas de todos sus clientes
if (this.data.isOrganizationalUnit && this.data.client?.clients) {
const clientIds = this.data.client.clients.map((client: any) => client.id);
if (clientIds.length > 0) {
// Agregar cada ID de cliente como un parámetro separado
clientIds.forEach((id: number) => {
params = params.append('client.id[]', id.toString());
});
} else {
this.traces = [];
this.length = 0;
this.loading = false;
return;
}
} else {
// Cliente individual
const clientId = this.data.client?.id;
if (!clientId) {
this.loading = false;
return;
}
params = params.set('client.id', clientId.toString());
}
const url = `${this.baseUrl}/traces`;
console.log('URL con parámetros:', url, params.toString());
this.http.get<any>(url, { params }).subscribe(
(data) => {
this.traces = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.loading = false;
},
(error) => {
console.error('Error fetching traces', error);
this.loading = false;
}
);
}
onOptionCommandSelected(selectedCommand: any): void {
this.filters['command'] = selectedCommand.name;
this.loadTraces();
}
onOptionStatusSelected(selectedStatus: any): void {
this.filters['status'] = selectedStatus;
this.loadTraces();
}
openInputModal(inputData: any): void {
this.dialog.open(InputDialogComponent, {
width: '70vw',
height: '60vh',
data: { input: inputData }
});
}
cancelTrace(trace: any): void {
if (trace.status !== 'pending' && trace.status !== 'in-progress') {
this.toastService.warning('Solo se pueden cancelar trazas pendientes o en ejecución', 'Advertencia');
return;
}
this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: trace.jobId },
}).afterClosed().subscribe((result) => {
if (result) {
if (trace.status === 'in-progress') {
this.http.post(`${this.baseUrl}/traces/${trace['@id']}/kill-job`, {
jobId: trace.jobId
}).subscribe({
next: () => {
this.toastService.success('Tarea cancelada correctamente');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description'] || 'Error al cancelar la tarea');
console.error('Error cancelling in-progress trace:', error);
}
});
} else {
this.http.post(`${this.baseUrl}/traces/server/${trace.uuid}/cancel`, {}).subscribe({
next: () => {
this.toastService.success('Tarea cancelada correctamente');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description'] || 'Error al cancelar la tarea');
console.error('Error cancelling pending trace:', error);
}
});
}
}
});
}
resetFilters(clientSearchCommandInput: any, clientSearchStatusInput: any) {
clientSearchCommandInput.value = '';
clientSearchStatusInput.value = '';
this.loadTraces();
}
openOutputModal(outputData: any): void {
this.dialog.open(OutputDialogComponent, {
width: '500px',
data: { input: outputData }
});
}
onPageChange(event: any): void {
this.page = event.pageIndex;
this.itemsPerPage = event.pageSize;
this.length = event.length;
this.loadTraces();
}
translateCommand(command: string): string {
return this.translationService.getCommandTranslation(command);
}
clearCommandFilter(event: Event, clientSearchCommandInput: any): void {
clientSearchCommandInput.value = '';
this.loadTraces();
}
clearStatusFilter(event: Event, clientSearchStatusInput: any): void {
clientSearchStatusInput.value = '';
this.loadTraces();
}
onDateFilterChange(): void {
this.loadTraces();
}
iniciarTour(): void {
this.joyrideService.startTour({
steps: [
'tracesTitleStep',
'resetFiltersStep',
'filtersStep',
'tracesProgressStep',
'tracesInfoStep',
'paginationStep'
],
showPrevButton: true,
themeColor: '#3f51b5'
});
}
close(): void {
this.dialogRef.close();
}
clearAllActions(): void {
if (this.traces.length === 0) {
this.toastService.warning('No hay acciones para limpiar');
return;
}
// Mostrar confirmación antes de proceder
this.dialog.open(DeleteModalComponent, {
width: '400px',
data: {
name: `Todas las acciones mostradas (${this.traces.length} acciones)`,
message: '¿Estás seguro de que quieres cancelar todas las acciones mostradas?'
},
}).afterClosed().subscribe((result) => {
if (result) {
this.loading = true;
// Enviar array de traces en el body
const tracesToCancel = this.traces.map((trace: any) => trace['@id']);
this.http.post(`${this.baseUrl}/traces/cancel-multiple`, {
traces: tracesToCancel
}).subscribe({
next: () => {
this.toastService.success(`Se han cancelado ${this.traces.length} acciones correctamente`);
this.loadTraces(); // Recargar las trazas
},
error: (error) => {
console.error('Error al cancelar las acciones:', error);
this.toastService.error('Error al cancelar las acciones');
this.loadTraces(); // Recargar las trazas para mostrar el estado actual
},
complete: () => {
this.loading = false;
}
});
}
});
}
}

View File

@ -10,6 +10,15 @@
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
background: white;
color: #333;
border-radius: 8px 8px 0 0;
}
.header-right {
display: flex;
gap: 8px;
align-items: center;
}
.header-container-title {
@ -18,6 +27,133 @@
margin-left: 1em;
}
.header-container-title h2 {
margin: 0;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.action-button {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 16px;
border-radius: 20px;
border: 1px solid #ddd;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
background: white;
color: #333;
}
.action-button:hover {
background: #f8f9fa;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-button.secondary {
background: #f8f9fa;
border-color: #adb5bd;
}
.action-button.secondary:hover {
background: #e9ecef;
}
/* Estadísticas */
.stats-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
padding: 0 10px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
border-left: 4px solid #667eea;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9rem;
color: #666;
font-weight: 500;
}
/* Filtros mejorados */
.filters-section {
background: #f8f9fa;
border-radius: 8px;
margin: 20px 0;
padding: 20px;
border: 1px solid #e9ecef;
}
.filters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.filters-header h3 {
margin: 0;
color: #495057;
font-weight: 500;
}
.search-container {
display: flex;
flex-direction: row;
gap: 15px;
transition: all 0.3s ease;
margin: 1.5rem 0rem 0.5rem 0rem;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
}
.search-container.expanded {
gap: 20px;
}
.filter-row {
display: flex;
gap: 15px;
align-items: center;
width: 100%;
}
.advanced-filters {
border-top: 1px solid #dee2e6;
padding-top: 15px;
margin-top: 10px;
}
.calendar-button-row {
display: flex;
gap: 15px;
@ -40,12 +176,9 @@ table {
width: 100%;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
margin: 1.5rem 0rem 0.5rem 0rem;
box-sizing: border-box;
.search-string {
flex: 1;
padding: 5px;
}
.search-boolean {
@ -64,39 +197,142 @@ table {
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.2);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
}
/* Tabla mejorada */
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.table-info {
color: #6c757d;
font-size: 0.9rem;
}
.table-actions {
display: flex;
gap: 10px;
}
.column-header {
display: flex;
align-items: center;
gap: 5px;
}
.sort-button {
opacity: 0.5;
transition: opacity 0.3s ease;
}
.sort-button:hover {
opacity: 1;
}
.sort-button.active {
opacity: 1;
color: #667eea;
}
/* Celdas mejoradas */
.command-cell, .client-cell, .date-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.command-name, .client-name {
font-weight: 500;
color: #212529;
}
.command-id, .client-ip {
font-size: 0.75rem;
color: #6c757d;
}
.date-time {
font-size: 0.85rem;
color: #212529;
}
.date-relative {
font-size: 0.7rem;
color: #6c757d;
font-style: italic;
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.progress-text {
font-size: 0.8rem;
font-weight: 500;
color: #667eea;
min-width: 35px;
}
/* Ajuste para el botón de cancelar en la barra de progreso */
.progress-container .cancel-button {
margin-left: auto;
flex-shrink: 0;
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
margin: 20px 0;
padding: 0 10px;
}
/* Chips de estado mejorados */
.chip-failed {
background-color: #e87979 !important;
color: white;
background-color: #ff6b6b !important;
color: white !important;
font-weight: 500;
}
.chip-success {
background-color: #46c446 !important;
color: white;
background-color: #51cf66 !important;
color: white !important;
font-weight: 500;
}
.chip-pending {
background-color: #bebdbd !important;
color: black;
background-color: #74c0fc !important;
color: white !important;
font-weight: 500;
}
.chip-in-progress {
background-color: #f5a623 !important;
color: white;
background-color: #ffd43b !important;
color: #212529 !important;
font-weight: 500;
}
.chip-cancelled {
background-color: #adb5bd !important;
color: white !important;
font-weight: 500;
}
.status-progress-flex {
@ -105,6 +341,48 @@ table {
gap: 8px;
}
/* Opciones de estado */
.status-option {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-indicator.failed { background-color: #dc3545; }
.status-indicator.success { background-color: #28a745; }
.status-indicator.pending { background-color: #17a2b8; }
.status-indicator.in-progress { background-color: #ffc107; }
.status-indicator.cancelled { background-color: #6c757d; }
/* Opciones de cliente */
.client-option {
display: flex;
flex-direction: column;
gap: 2px;
}
.client-name {
font-weight: 500;
}
.client-details {
font-size: 0.8rem;
color: #6c757d;
}
/* Botones de acción */
.action-buttons {
display: flex;
gap: 5px;
justify-content: center;
}
button.cancel-button {
display: flex;
align-items: center;
@ -113,12 +391,120 @@ button.cancel-button {
}
.cancel-button {
color: red;
color: #dc3545;
background-color: transparent;
border: none;
padding: 0;
transition: all 0.3s ease;
}
.cancel-button:hover {
background-color: rgba(220, 53, 69, 0.1);
border-radius: 50%;
}
.cancel-button mat-icon {
color: red;
color: #dc3545;
}
/* Filas seleccionadas */
.selected-row {
background-color: rgba(102, 126, 234, 0.1) !important;
}
.mat-row:hover {
background-color: rgba(102, 126, 234, 0.05);
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 15px;
text-align: center;
background: white;
color: #333;
}
.header-actions {
width: 100%;
justify-content: center;
}
.stats-container {
grid-template-columns: repeat(2, 1fr);
}
.filter-row {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
gap: 10px;
text-align: center;
}
.action-buttons {
flex-direction: column;
gap: 2px;
}
}
/* Animaciones */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.stats-container, .filters-section, .table-container {
animation: fadeIn 0.5s ease-out;
}
/* Estados de carga */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* Botón de cerrar en footer */
.footer-actions {
display: flex;
justify-content: flex-end;
padding: 20px 0;
border-top: 1px solid #e9ecef;
margin-top: 20px;
}
.footer-actions button {
min-width: 120px;
padding: 10px 24px;
font-weight: 500;
border-radius: 8px;
transition: all 0.3s ease;
background-color: white;
color: #333;
border: 1px solid #ddd;
}
.footer-actions button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
background-color: #f8f9fa;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}

View File

@ -1,4 +1,4 @@
<div class="modal-content">
<mat-dialog-content class="modal-content">
<div class="header-container">
<button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon>
@ -9,7 +9,7 @@
translate }}</h2>
</div>
<div class="images-button-row">
<div class="header-right">
<button class="action-button" (click)="resetFilters(commandSearchInput, commandStatusInput)"
joyrideStep="resetFiltersStep" text="{{ 'resetFiltersStepText' | translate }}">
{{ 'resetFilters' | translate }}
@ -82,6 +82,10 @@
[bufferValue]="bufferValue">
</mat-progress-bar>
<span>{{trace.progress}}%</span>
<button mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="Cancelar tarea">
<mat-icon>cancel</mat-icon>
</button>
</div>
</ng-container>
<ng-template #statusChip>
@ -103,8 +107,8 @@
trace.status
}}
</mat-chip>
<button *ngIf="trace.status === 'in-progress' && trace.command === 'deploy-image'" mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="Cancelar transmisión de imagen">
<button *ngIf="trace.status === 'in-progress'" mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="Cancelar tarea">
<mat-icon>cancel</mat-icon>
</button>
</div>
@ -176,4 +180,8 @@
(page)="onPageChange($event)">
</mat-paginator>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" style="padding: 16px 24px;">
<button class="ordinary-button" (click)="closeDialog()">{{ 'closeButton' | translate }}</button>
</mat-dialog-actions>

View File

@ -175,21 +175,41 @@ export class ClientTaskLogsComponent implements OnInit {
}
cancelTrace(trace: any): void {
if (trace.status !== 'pending' && trace.status !== 'in-progress') {
this.toastService.warning('Solo se pueden cancelar trazas pendientes o en ejecución', 'Advertencia');
return;
}
this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: trace.jobId },
}).afterClosed().subscribe((result) => {
if (result) {
this.http.post(`${this.baseUrl}/traces/server/${trace.uuid}/cancel`, {}).subscribe({
next: () => {
this.toastService.success('Transmision de imagen cancelada');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
console.error(error.error['hydra:description']);
}
});
if (trace.status === 'in-progress') {
this.http.post(`${this.baseUrl}/traces/${trace['@id']}/cancel`, {
job_id: trace.jobId
}).subscribe({
next: () => {
this.toastService.success('Tarea cancelada correctamente');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description'] || 'Error al cancelar la tarea');
console.error('Error cancelling in-progress trace:', error);
}
});
} else {
this.http.post(`${this.baseUrl}/traces/server/${trace.uuid}/cancel`, {}).subscribe({
next: () => {
this.toastService.success('Tarea cancelada correctamente');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description'] || 'Error al cancelar la tarea');
console.error('Error cancelling pending trace:', error);
}
});
}
}
});
}
@ -311,4 +331,8 @@ export class ClientTaskLogsComponent implements OnInit {
themeColor: '#3f51b5'
});
}
closeDialog(): void {
this.dialog.closeAll();
}
}

View File

@ -4,6 +4,9 @@
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
background: white;
color: #333;
border-radius: 8px 8px 0 0;
}
.header-container-title {
@ -12,6 +15,182 @@
margin-left: 1em;
}
.header-container-title h2 {
margin: 0;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.action-button {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 16px;
border-radius: 20px;
border: 1px solid #ddd;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
background: white;
color: #333;
}
.action-button:hover {
background: #f8f9fa;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-button.secondary {
background: #f8f9fa;
border-color: #adb5bd;
}
.action-button.secondary:hover {
background: #e9ecef;
}
.stats-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
padding: 0 10px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
border-left: 4px solid #667eea;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
}
.stat-total {
border-left-color: #667eea;
background: #667eea;
color: white;
}
.stat-total .stat-number,
.stat-total .stat-label {
color: white;
}
.stat-today {
border-left-color: #17a2b8;
background: #17a2b8;
color: white;
}
.stat-today .stat-number,
.stat-today .stat-label {
color: white;
}
.stat-success {
border-left-color: #28a745;
background: #28a745;
color: white;
}
.stat-success .stat-number,
.stat-success .stat-label {
color: white;
}
.stat-failed {
border-left-color: #dc3545;
background: #dc3545;
color: white;
}
.stat-failed .stat-number,
.stat-failed .stat-label {
color: white;
}
.stat-in-progress {
border-left-color: #ffc107;
background: #ffc107;
color: #212529;
}
.stat-in-progress .stat-number,
.stat-in-progress .stat-label {
color: #212529;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9rem;
color: #666;
font-weight: 500;
}
.filters-section {
background: #f8f9fa;
border-radius: 8px;
margin: 20px 0;
padding: 20px;
border: 1px solid #e9ecef;
}
.filters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.filters-header h3 {
margin: 0;
color: #495057;
font-weight: 500;
}
.search-container {
display: flex;
flex-direction: column;
gap: 15px;
transition: all 0.3s ease;
}
.search-container.expanded {
gap: 20px;
}
.filter-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
align-items: end;
}
.advanced-filters {
border-top: 1px solid #dee2e6;
padding-top: 15px;
margin-top: 10px;
}
.calendar-button-row {
display: flex;
gap: 15px;
@ -34,14 +213,6 @@ table {
width: 100%;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
margin: 1.5rem 0rem 1.5rem 0rem;
box-sizing: border-box;
}
.search-string {
flex: 1;
padding: 5px;
@ -57,40 +228,145 @@ table {
padding: 5px;
}
.search-date {
flex: 1;
padding: 5px;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.2);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
}
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.table-info {
color: #6c757d;
font-size: 0.9rem;
}
.table-actions {
display: flex;
gap: 10px;
}
.column-header {
display: flex;
align-items: center;
gap: 5px;
}
.sort-button {
opacity: 0.5;
transition: opacity 0.3s ease;
}
.sort-button:hover {
opacity: 1;
}
.sort-button.active {
opacity: 1;
color: #667eea;
}
.command-cell, .client-cell, .date-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.command-name, .client-name {
font-weight: 500;
color: #212529;
}
.command-id, .client-ip {
font-size: 0.75rem;
color: #6c757d;
}
.date-time {
font-size: 0.85rem;
color: #212529;
}
.date-relative {
font-size: 0.7rem;
color: #6c757d;
font-style: italic;
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.progress-text {
font-size: 0.8rem;
font-weight: 500;
color: #667eea;
min-width: 35px;
}
/* Ajuste para el botón de cancelar en la barra de progreso */
.progress-container .cancel-button {
margin-left: auto;
flex-shrink: 0;
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
margin: 20px 0;
padding: 0 10px;
}
.chip-failed {
background-color: #e87979 !important;
color: white;
background-color: #ff6b6b !important;
color: white !important;
font-weight: 500;
}
.chip-success {
background-color: #46c446 !important;
color: white;
background-color: #51cf66 !important;
color: white !important;
font-weight: 500;
}
.chip-pending {
background-color: #bebdbd !important;
color: black;
background-color: #74c0fc !important;
color: white !important;
font-weight: 500;
}
.chip-in-progress {
background-color: #f5a623 !important;
color: white;
background-color: #ffd43b !important;
color: #212529 !important;
font-weight: 500;
}
.chip-cancelled {
background-color: #adb5bd !important;
color: white !important;
font-weight: 500;
}
.status-progress-flex {
@ -99,6 +375,45 @@ table {
gap: 8px;
}
.status-option {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-indicator.failed { background-color: #dc3545; }
.status-indicator.success { background-color: #28a745; }
.status-indicator.pending { background-color: #17a2b8; }
.status-indicator.in-progress { background-color: #ffc107; }
.status-indicator.cancelled { background-color: #6c757d; }
.client-option {
display: flex;
flex-direction: column;
gap: 2px;
}
.client-name {
font-weight: 500;
}
.client-details {
font-size: 0.8rem;
color: #6c757d;
}
.action-buttons {
display: flex;
gap: 5px;
justify-content: center;
}
button.cancel-button {
display: flex;
align-items: center;
@ -107,12 +422,83 @@ button.cancel-button {
}
.cancel-button {
color: red;
color: #dc3545;
background-color: transparent;
border: none;
padding: 0;
transition: all 0.3s ease;
}
.cancel-button:hover {
background-color: rgba(220, 53, 69, 0.1);
border-radius: 50%;
}
.cancel-button mat-icon {
color: red;
color: #dc3545;
}
.selected-row {
background-color: rgba(102, 126, 234, 0.1) !important;
}
.mat-row:hover {
background-color: rgba(102, 126, 234, 0.05);
cursor: pointer;
}
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 15px;
text-align: center;
background: white;
color: #333;
}
.header-actions {
width: 100%;
justify-content: center;
}
.stats-container {
grid-template-columns: repeat(2, 1fr);
}
.filter-row {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
gap: 10px;
text-align: center;
}
.action-buttons {
flex-direction: column;
gap: 2px;
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.stats-container, .filters-section, .table-container {
animation: fadeIn 0.5s ease-out;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}

View File

@ -1,5 +1,5 @@
<div class="header-container">
<button mat-icon-button color="primary" (click)="iniciarTour()">
<button mat-icon-button color="primary" (click)="iniciarTour()" matTooltip="Ayuda">
<mat-icon>help</mat-icon>
</button>
@ -8,91 +8,181 @@
translate }}</h2>
</div>
<div class="images-button-row">
<div class="header-actions">
<button class="action-button secondary" (click)="exportToCSV()" matTooltip="Exportar a CSV">
<mat-icon>download</mat-icon>
{{ 'exportCSV' | translate }}
</button>
<button class="action-button" (click)="resetFilters(commandSearchInput, commandStatusInput, commandClientInput)"
joyrideStep="resetFiltersStep" text="{{ 'resetFiltersStepText' | translate }}">
<mat-icon>refresh</mat-icon>
{{ 'resetFilters' | translate }}
</button>
</div>
</div>
<div class="search-container" joyrideStep="filtersStep" text="{{ 'filtersStepText' | translate }}">
<mat-form-field appearance="fill" class="search-select">
<input type="text" matInput [formControl]="clientControl" [matAutocomplete]="clientAuto" #commandClientInput
placeholder="{{ 'filterClientPlaceholder' | translate }}">
<mat-autocomplete #clientAuto="matAutocomplete" [displayWith]="displayFnClient"
(optionSelected)="onOptionClientSelected($event.option.value)">
<mat-option *ngFor="let client of filteredClients | async" [value]="client">
<div style="display: flex; flex-direction: column;">
<span>{{ client.name }}</span>
<span style="font-size: 0.8rem; color: gray;">
{{ client.ip }} — {{ client.mac }}
</span>
</div>
</mat-option>
<div class="stats-container" *ngIf="!loading">
<div class="stat-card stat-total">
<div class="stat-number">{{ totalStats.total }}</div>
<div class="stat-label">{{ 'totalTraces' | translate }}</div>
</div>
<div class="stat-card stat-today">
<div class="stat-number">{{ getStatusCount('today') }}</div>
<div class="stat-label">{{ 'todayTraces' | translate }}</div>
</div>
<div class="stat-card stat-success">
<div class="stat-number">{{ getStatusCount('success') }}</div>
<div class="stat-label">{{ 'successful' | translate }}</div>
</div>
<div class="stat-card stat-failed">
<div class="stat-number">{{ getStatusCount('failed') }}</div>
<div class="stat-label">{{ 'failed' | translate }}</div>
</div>
<div class="stat-card stat-in-progress">
<div class="stat-number">{{ getStatusCount('in-progress') }}</div>
<div class="stat-label">{{ 'inProgress' | translate }}</div>
</div>
</div>
</mat-autocomplete>
<button *ngIf="commandClientInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearClientFilter($event, commandClientInput)">
<mat-icon>close</mat-icon>
<div class="filters-section" joyrideStep="filtersStep" text="{{ 'filtersStepText' | translate }}">
<div class="filters-header">
<h3>{{ 'filters' | translate }}</h3>
<button mat-button color="primary" (click)="toggleFilters()">
<mat-icon>{{ showAdvancedFilters ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ showAdvancedFilters ? 'hideAdvanced' : 'showAdvanced' | translate }}
</button>
<mat-hint>Por favor, ingrese el nombre del cliente</mat-hint>
</mat-form-field>
</div>
<mat-form-field appearance="fill" class="search-select">
<mat-label>{{ 'commandSelectStepText' | translate }}</mat-label>
<mat-select (selectionChange)="onOptionCommandSelected($event.value)" #commandSearchInput>
<mat-option *ngFor="let command of filteredCommands2" [value]="command">
{{ translateCommand(command.name) }}
</mat-option>
</mat-select>
<button *ngIf="commandSearchInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearCommandFilter($event, commandSearchInput)">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<div class="search-container" [class.expanded]="showAdvancedFilters">
<div class="filter-row">
<mat-form-field appearance="fill" class="search-select">
<input type="text" matInput [formControl]="clientControl" [matAutocomplete]="clientAuto" #commandClientInput
placeholder="{{ 'filterClientPlaceholder' | translate }}">
<mat-autocomplete #clientAuto="matAutocomplete" [displayWith]="displayFnClient"
(optionSelected)="onOptionClientSelected($event.option.value)">
<mat-option *ngFor="let client of filteredClients | async" [value]="client">
<div class="client-option">
<span class="client-name">{{ client.name }}</span>
<span class="client-details">{{ client.ip }} — {{ client.mac }}</span>
</div>
</mat-option>
</mat-autocomplete>
<button *ngIf="commandClientInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearClientFilter($event, commandClientInput)">
<mat-icon>close</mat-icon>
</button>
<mat-hint>{{ 'enterClientName' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="search-select">
<mat-label>{{ 'commandSelectStepText' | translate }}</mat-label>
<mat-select (selectionChange)="onOptionCommandSelected($event.value)" #commandSearchInput>
<mat-option *ngFor="let command of filteredCommands2" [value]="command">
{{ translateCommand(command.name) }}
</mat-option>
</mat-select>
<button *ngIf="commandSearchInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearCommandFilter($event, commandSearchInput)">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="fill" class="search-boolean">
<mat-label i18n="@@searchLabel">Estado</mat-label>
<mat-select (selectionChange)="onOptionStatusSelected($event.value)" placeholder="Seleccionar opción"
#commandStatusInput>
<mat-option [value]="'failed'">Fallido</mat-option>
<mat-option [value]="'pending'">Pendiente de ejecutar</mat-option>
<mat-option [value]="'in-progress'">Ejecutando</mat-option>
<mat-option [value]="'success'">Completado con éxito</mat-option>
<mat-option [value]="'cancelled'">Cancelado</mat-option>
</mat-select>
<button *ngIf="commandStatusInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearStatusFilter($event, commandStatusInput)">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="fill" class="search-boolean">
<mat-label>{{ 'status' | translate }}</mat-label>
<mat-select (selectionChange)="onOptionStatusSelected($event.value)" placeholder="Seleccionar opción"
#commandStatusInput>
<mat-option [value]="'failed'">
<div class="status-option">
<div class="status-indicator failed"></div>
{{ 'failed' | translate }}
</div>
</mat-option>
<mat-option [value]="'pending'">
<div class="status-option">
<div class="status-indicator pending"></div>
{{ 'pending' | translate }}
</div>
</mat-option>
<mat-option [value]="'in-progress'">
<div class="status-option">
<div class="status-indicator in-progress"></div>
{{ 'inProgress' | translate }}
</div>
</mat-option>
<mat-option [value]="'success'">
<div class="status-option">
<div class="status-indicator success"></div>
{{ 'success' | translate }}
</div>
</mat-option>
<mat-option [value]="'cancelled'">
<div class="status-option">
<div class="status-indicator cancelled"></div>
{{ 'cancelled' | translate }}
</div>
</mat-option>
</mat-select>
<button *ngIf="commandStatusInput.value" mat-icon-button matSuffix aria-label="Clear input search"
(click)="clearStatusFilter($event, commandStatusInput)">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</div>
<mat-form-field appearance="fill" class="search-date">
<mat-label>Desde</mat-label>
<input matInput [matDatepicker]="fromPicker" [(ngModel)]="filters['startDate']"
(dateChange)="onDateFilterChange()" [max]="today">
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<div class="filter-row advanced-filters" *ngIf="showAdvancedFilters">
<mat-form-field appearance="fill" class="search-date">
<mat-label>{{ 'fromDate' | translate }}</mat-label>
<input matInput [matDatepicker]="fromPicker" [(ngModel)]="filters['startDate']"
(dateChange)="onDateFilterChange()" [max]="today">
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="search-date">
<mat-label>Hasta</mat-label>
<input matInput [matDatepicker]="toPicker" [(ngModel)]="filters['endDate']" (dateChange)="onDateFilterChange()"
[max]="today">
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="search-date">
<mat-label>{{ 'toDate' | translate }}</mat-label>
<input matInput [matDatepicker]="toPicker" [(ngModel)]="filters['endDate']" (dateChange)="onDateFilterChange()"
[max]="today">
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="search-select">
<mat-label>{{ 'sortBy' | translate }}</mat-label>
<mat-select [(ngModel)]="sortBy" (selectionChange)="onSortChange()">
<mat-option value="executedAt">{{ 'executionDate' | translate }}</mat-option>
<mat-option value="status">{{ 'status' | translate }}</mat-option>
<mat-option value="command">{{ 'command' | translate }}</mat-option>
<mat-option value="client">{{ 'client' | translate }}</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
<app-loading [isLoading]="loading"></app-loading>
<div *ngIf="!loading">
<div *ngIf="!loading" class="table-container">
<div class="table-header">
<div class="table-info">
<span>{{ 'showingResults' | translate: { from: getPaginationFrom(), to: getPaginationTo(), total: getPaginationTotal() } }}</span>
</div>
<div class="table-actions">
<button mat-icon-button (click)="refreshData()" matTooltip="{{ 'refresh' | translate }}">
<mat-icon>refresh</mat-icon>
</button>
</div>
</div>
<table mat-table [dataSource]="traces" class="mat-elevation-z8">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ column.header }}</span>
<button *ngIf="column.sortable !== false" mat-icon-button (click)="sortColumn(column.columnDef)" class="sort-button">
<mat-icon>{{ getSortIcon(column.columnDef) }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let trace">
<ng-container [ngSwitch]="column.columnDef">
@ -102,7 +192,11 @@
<mat-progress-bar class="example-margin" [mode]="mode" [value]="trace.progress"
[bufferValue]="bufferValue">
</mat-progress-bar>
<span>{{trace.progress}}%</span>
<span class="progress-text">{{trace.progress}}%</span>
<button mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="{{ 'cancelTask' | translate }}">
<mat-icon>cancel</mat-icon>
</button>
</div>
</ng-container>
<ng-template #statusChip>
@ -116,16 +210,16 @@
'chip-cancelled': trace.status === 'cancelled'
}">
{{
trace.status === 'failed' ? 'Error' :
trace.status === 'in-progress' ? 'En ejecución' :
trace.status === 'success' ? 'Completado' :
trace.status === 'pending' ? 'Pendiente' :
trace.status === 'cancelled' ? 'Cancelado' :
trace.status === 'failed' ? ('failed' | translate) :
trace.status === 'in-progress' ? ('inProgress' | translate) :
trace.status === 'success' ? ('successful' | translate) :
trace.status === 'pending' ? ('pending' | translate) :
trace.status === 'cancelled' ? ('cancelled' | translate) :
trace.status
}}
</mat-chip>
<button *ngIf="trace.status === 'in-progress' && trace.command === 'deploy-image'" mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="Cancelar transmisión de imagen">
<button *ngIf="trace.status === 'in-progress'" mat-icon-button
(click)="cancelTrace(trace)" class="cancel-button" matTooltip="{{ 'cancelTask' | translate }}">
<mat-icon>cancel</mat-icon>
</button>
</div>
@ -133,29 +227,30 @@
</ng-container>
<ng-container *ngSwitchCase="'command'">
<div style="display: flex; flex-direction: column;">
<span>{{ translateCommand(trace.command) }}</span>
<span style="font-size: 0.75rem; color: gray;">{{ trace.jobId }}</span>
<div class="command-cell">
<span class="command-name">{{ translateCommand(trace.command) }}</span>
<span class="command-id">{{ trace.jobId }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'client'">
<div style="display: flex; flex-direction: column;">
<span>{{ trace.client?.name }}</span>
<span style="font-size: 0.75rem; color: gray;">{{ trace.client?.ip }}</span>
<div class="client-cell">
<span class="client-name">{{ trace.client?.name }}</span>
<span class="client-ip">{{ trace.client?.ip }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'executedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.executedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<div class="date-cell">
<span class="date-time">{{ trace.executedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span class="date-relative" *ngIf="getRelativeTime(trace.executedAt)">{{ getRelativeTime(trace.executedAt) }}</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="'finishedAt'">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem;"> {{ trace.finishedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<div class="date-cell">
<span class="date-time">{{ trace.finishedAt |date: 'dd/MM/yyyy hh:mm:ss'}}</span>
<span class="date-relative" *ngIf="getRelativeTime(trace.finishedAt)">{{ getRelativeTime(trace.finishedAt) }}</span>
</div>
</ng-container>
<ng-container *ngSwitchDefault>
@ -170,26 +265,31 @@
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'informationLabel' | translate }}</th>
<td mat-cell *matCellDef="let trace" style="text-align: center;" joyrideStep="tracesInfoStep"
text="{{ 'tracesInfoStepText' | translate }}">
<button mat-icon-button color="primary" [disabled]="!trace.input || trace.input.length === 0"
(click)="openInputModal(trace.input)">
<mat-icon>
<span class="material-symbols-outlined">
mode_comment
</span>
</mat-icon>
</button>
<button mat-icon-button color="primary" [disabled]="!trace.output" (click)="openOutputModal(trace.output)">
<mat-icon>
<span class="material-symbols-outlined">
info
</span>
</mat-icon>
</button>
<div class="action-buttons">
<button mat-icon-button color="primary" [disabled]="!trace.input || trace.input.length === 0"
(click)="openInputModal(trace.input)" matTooltip="{{ 'viewInput' | translate }}">
<mat-icon>
<span class="material-symbols-outlined">mode_comment</span>
</mat-icon>
</button>
<button mat-icon-button color="primary" [disabled]="!trace.output" (click)="openOutputModal(trace.output)"
matTooltip="{{ 'viewOutput' | translate }}">
<mat-icon>
<span class="material-symbols-outlined">info</span>
</mat-icon>
</button>
<button mat-icon-button color="warn" *ngIf="trace.status === 'pending'"
(click)="cancelTrace(trace)" matTooltip="{{ 'cancelTrace' | translate }}">
<mat-icon>cancel</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"
[class.selected-row]="selectedTrace?.id === row.id"
(click)="selectTrace(row)"></tr>
</table>
</div>

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnInit, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin } from 'rxjs';
import { FormControl } from '@angular/forms';
@ -20,7 +20,7 @@ import { COMMAND_TYPES } from '../../shared/constants/command-types';
templateUrl: './task-logs.component.html',
styleUrls: ['./task-logs.component.css']
})
export class TaskLogsComponent implements OnInit {
export class TaskLogsComponent implements OnInit, OnDestroy {
baseUrl: string;
mercureUrl: string;
traces: any[] = [];
@ -38,6 +38,30 @@ export class TaskLogsComponent implements OnInit {
bufferValue = 0;
today = new Date();
showAdvancedFilters: boolean = false;
selectedTrace: any = null;
sortBy: string = 'executedAt';
sortDirection: 'asc' | 'desc' = 'desc';
currentSortColumn: string = 'executedAt';
totalStats: {
total: number;
success: number;
failed: number;
pending: number;
inProgress: number;
cancelled: number;
today: number;
} = {
total: 0,
success: 0,
failed: 0,
pending: 0,
inProgress: 0,
cancelled: 0,
today: 0
};
filteredCommands2 = Object.keys(COMMAND_TYPES).map(key => ({
name: key,
value: key,
@ -49,31 +73,37 @@ export class TaskLogsComponent implements OnInit {
columnDef: 'id',
header: 'ID',
cell: (trace: any) => `${trace.id}`,
sortable: true
},
{
columnDef: 'command',
header: 'Comando',
cell: (trace: any) => trace.command
cell: (trace: any) => trace.command,
sortable: true
},
{
columnDef: 'client',
header: 'Cliente',
cell: (trace: any) => trace.client?.name
cell: (trace: any) => trace.client?.name,
sortable: true
},
{
columnDef: 'status',
header: 'Estado',
cell: (trace: any) => trace.status
cell: (trace: any) => trace.status,
sortable: true
},
{
columnDef: 'executedAt',
header: 'Ejecución',
cell: (trace: any) => this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
sortable: true
},
{
columnDef: 'finishedAt',
header: 'Finalización',
cell: (trace: any) => this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
sortable: true
},
];
displayedColumns = [...this.columns.map(column => column.columnDef), 'information'];
@ -100,6 +130,7 @@ export class TaskLogsComponent implements OnInit {
this.loadTraces();
this.loadCommands();
this.loadClients();
this.loadTotalStats();
this.filteredCommands = this.commandControl.valueChanges.pipe(
startWith(''),
map(value => (typeof value === 'string' ? value : value?.name)),
@ -122,6 +153,169 @@ export class TaskLogsComponent implements OnInit {
}
}
ngOnDestroy(): void {
}
toggleFilters(): void {
this.showAdvancedFilters = !this.showAdvancedFilters;
}
refreshData(): void {
this.loadTraces();
this.toastService.success('Datos actualizados', 'Éxito');
}
selectTrace(trace: any): void {
this.selectedTrace = trace;
}
sortColumn(columnDef: string): void {
if (this.currentSortColumn === columnDef) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.currentSortColumn = columnDef;
this.sortDirection = 'desc';
}
this.sortBy = columnDef;
this.onSortChange();
}
getSortIcon(columnDef: string): string {
if (this.currentSortColumn !== columnDef) {
return 'unfold_more';
}
return this.sortDirection === 'asc' ? 'expand_less' : 'expand_more';
}
onSortChange(): void {
this.loadTraces();
}
getStatusCount(status: string): number {
switch(status) {
case 'success':
return this.totalStats.success;
case 'failed':
return this.totalStats.failed;
case 'pending':
return this.totalStats.pending;
case 'in-progress':
return this.totalStats.inProgress;
case 'cancelled':
return this.totalStats.cancelled;
case 'today':
return this.totalStats.today;
default:
return 0;
}
}
getTodayTracesCount(): number {
const today = new Date();
const todayString = this.datePipe.transform(today, 'yyyy-MM-dd');
return this.traces.filter(trace =>
trace.executedAt && trace.executedAt.startsWith(todayString)
).length;
}
getRelativeTime(date: string): string {
if (!date) return '';
const now = new Date();
const traceDate = new Date(date);
const diffInSeconds = Math.floor((now.getTime() - traceDate.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'hace un momento';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `hace ${minutes} minuto${minutes > 1 ? 's' : ''}`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `hace ${hours} hora${hours > 1 ? 's' : ''}`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `hace ${days} día${days > 1 ? 's' : ''}`;
}
}
exportToCSV(): void {
const headers = ['ID', 'Comando', 'Cliente', 'Estado', 'Fecha Ejecución', 'Fecha Finalización', 'Job ID'];
const csvData = this.traces.map(trace => [
trace.id,
this.translateCommand(trace.command),
trace.client?.name || '',
trace.status,
this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss'),
this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss'),
trace.jobId || ''
]);
const csvContent = [headers, ...csvData]
.map(row => row.map(cell => `"${cell}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `traces_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.toastService.success('Archivo CSV exportado correctamente', 'Éxito');
}
cancelTrace(trace: any): void {
if (trace.status !== 'pending' && trace.status !== 'in-progress') {
this.toastService.warning('Solo se pueden cancelar trazas pendientes o en ejecución', 'Advertencia');
return;
}
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '400px',
data: {
title: 'Cancelar Traza',
message: `¿Estás seguro de que quieres cancelar la traza #${trace.id}?`,
confirmText: 'Cancelar',
cancelText: 'No cancelar'
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
if (trace.status === 'in-progress') {
this.http.post(`${this.baseUrl}${trace['@id']}/kill-job`, {
jobId: trace.jobId
}).subscribe(
() => {
this.toastService.success('Traza cancelada correctamente', 'Éxito');
this.loadTraces();
this.loadTotalStats();
},
(error) => {
this.toastService.error('Error al cancelar la traza', 'Error');
}
);
} else {
this.http.post(`${this.baseUrl}/traces/cancel-multiple`, {traces: [trace['@id']]}).subscribe(
() => {
this.toastService.success('Traza cancelada correctamente', 'Éxito');
this.loadTraces();
this.loadTotalStats();
},
(error) => {
console.error('Error cancelling pending trace:', error);
this.toastService.error('Error al cancelar la traza', 'Error');
}
);
}
}
});
}
private updateTracesStatus(clientUuid: string, newStatus: string, progress: Number): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === clientUuid);
if (traceIndex !== -1) {
@ -135,14 +329,9 @@ export class TaskLogsComponent implements OnInit {
this.traces = updatedTraces;
this.cdr.detectChanges();
console.log(`Estado actualizado para la traza ${clientUuid}: ${newStatus}`);
} else {
console.warn(`Traza con UUID ${clientUuid} no encontrado en la lista.`);
}
}
private _filterClients(value: string): any[] {
const filterValue = value.toLowerCase();
@ -153,7 +342,6 @@ export class TaskLogsComponent implements OnInit {
);
}
private _filterCommands(name: string): any[] {
const filterValue = name.toLowerCase();
return this.commands.filter(command => command.name.toLowerCase().includes(filterValue));
@ -193,30 +381,11 @@ export class TaskLogsComponent implements OnInit {
});
}
cancelTrace(trace: any): void {
this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: trace.jobId },
}).afterClosed().subscribe((result) => {
if (result) {
this.http.post(`${this.baseUrl}/traces/server/${trace.uuid}/cancel`, {}).subscribe({
next: () => {
this.toastService.success('Transmision de imagen cancelada');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
console.error(error.error['hydra:description']);
}
});
}
});
}
loadTraces(): void {
this.loading = true;
const url = `${this.baseUrl}/traces?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`;
const params: any = { ...this.filters };
if (params['status'] === undefined) {
delete params['status'];
}
@ -230,12 +399,19 @@ export class TaskLogsComponent implements OnInit {
delete params['endDate'];
}
if (this.sortBy) {
params['order[' + this.sortBy + ']'] = this.sortDirection;
}
this.http.get<any>(url, { params }).subscribe(
(data) => {
this.traces = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.groupedTraces = this.groupByCommandId(this.traces);
this.loading = false;
if (Object.keys(this.filters).length === 0) {
this.loadTotalStats();
}
},
(error) => {
console.error('Error fetching traces', error);
@ -272,6 +448,52 @@ export class TaskLogsComponent implements OnInit {
);
}
loadTotalStats(): void {
this.calculateLocalStats();
}
private calculateLocalStats(): void {
const statuses = ['success', 'failed', 'pending', 'in-progress', 'cancelled'];
const requests = statuses.map(status =>
this.http.get<any>(`${this.baseUrl}/traces?status=${status}&page=1&itemsPerPage=1`)
);
const totalRequest = this.http.get<any>(`${this.baseUrl}/traces?page=1&itemsPerPage=1`);
const todayString = this.datePipe.transform(new Date(), 'yyyy-MM-dd');
const todayRequest = this.http.get<any>(`${this.baseUrl}/traces?executedAt[after]=${todayString}&page=1&itemsPerPage=1`);
forkJoin([totalRequest, ...requests, todayRequest]).subscribe(
(responses) => {
const totalData = responses[0];
const statusData = responses.slice(1, 6);
const todayData = responses[6];
this.totalStats = {
total: totalData['hydra:totalItems'],
success: statusData[0]['hydra:totalItems'],
failed: statusData[1]['hydra:totalItems'],
pending: statusData[2]['hydra:totalItems'],
inProgress: statusData[3]['hydra:totalItems'],
cancelled: statusData[4]['hydra:totalItems'],
today: todayData['hydra:totalItems']
};
},
error => {
console.error('Error fetching stats by status:', error);
const todayString = this.datePipe.transform(new Date(), 'yyyy-MM-dd');
this.totalStats = {
total: this.length,
success: this.traces.filter(trace => trace.status === 'success').length,
failed: this.traces.filter(trace => trace.status === 'failed').length,
pending: this.traces.filter(trace => trace.status === 'pending').length,
inProgress: this.traces.filter(trace => trace.status === 'in-progress').length,
cancelled: this.traces.filter(trace => trace.status === 'cancelled').length,
today: this.traces.filter(trace => trace.executedAt && trace.executedAt.startsWith(todayString)).length
};
}
);
}
resetFilters(clientSearchCommandInput: any, clientSearchStatusInput: any, clientSearchClientInput: any) {
this.loading = true;
@ -361,4 +583,16 @@ export class TaskLogsComponent implements OnInit {
});
}
// Métodos para paginación
getPaginationFrom(): number {
return (this.page * this.itemsPerPage) + 1;
}
getPaginationTo(): number {
return Math.min((this.page + 1) * this.itemsPerPage, this.length);
}
getPaginationTotal(): number {
return this.length;
}
}

View File

@ -2,8 +2,52 @@ mat-toolbar {
/*height: 7vh;*/
min-height: 65px;
min-width: 375px;
background-color: #e2e8f0;
background: rgba(226, 232, 240, 0.8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: black;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 0 16px;
}
/* Estilos específicos para el botón del sidebar */
.navbar-icon {
color: #3f51b5;
font-size: 24px;
width: 24px;
height: 24px;
}
/* Asegurar que el botón del sidebar sea visible */
mat-toolbar button[mat-icon-button] {
display: flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 48px;
border-radius: 50%;
transition: all 0.3s ease;
color: #3f51b5;
background-color: transparent;
border: none;
cursor: pointer;
margin-right: 8px;
}
mat-toolbar button[mat-icon-button]:hover {
background-color: rgba(63, 81, 181, 0.1);
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(63, 81, 181, 0.2);
}
.navbar-actions-row {
@ -11,6 +55,7 @@ mat-toolbar {
justify-content: end;
align-items: center;
flex-grow: 1;
gap: 8px;
}
.navbar-buttons-row {
@ -71,6 +116,7 @@ mat-toolbar {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.trace-button .mat-icon {
@ -81,3 +127,17 @@ mat-toolbar {
margin-right: 2vh;
}
}
.menu-toggle-right {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
z-index: 1100;
}
@media (max-width: 576px) {
.menu-toggle-right {
right: 8px;
}
}

View File

@ -3,12 +3,11 @@
matTooltipShowDelay="1000">
</span>
<button mat-icon-button (click)="onToggleSidebar()" matTooltip="Abrir o cerrar la barra lateral"
matTooltipShowDelay="1000">
<mat-icon class="navbar-icon">menu</mat-icon>
</button>
<div class="navbar-actions-row" *ngIf="!isSmallScreen">
<button mat-icon-button (click)="onToggleSidebar()" matTooltip="Abrir o cerrar la barra lateral"
matTooltipShowDelay="1000">
<mat-icon class="navbar-icon">menu</mat-icon>
</button>
<button routerLink="/commands-logs" mat-button>
<mat-icon class="trace-button" >notifications</mat-icon>
</button>
@ -37,6 +36,10 @@
<!-- Menú desplegable para pantallas pequeñas -->
<div *ngIf="isSmallScreen" class="isSmallScreenButtons">
<button mat-icon-button (click)="onToggleSidebar()" matTooltip="Abrir o cerrar la barra lateral"
matTooltipShowDelay="1000">
<mat-icon class="navbar-icon">menu</mat-icon>
</button>
<button class="trace-button" routerLink="/commands-logs" mat-button>
<mat-icon>notifications</mat-icon>
</button>

View File

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

View File

@ -6,6 +6,8 @@ html, body {
.container {
height: 100%;
padding-top: 65px; /* Asegurar que el contenido no se superponga con el header fijo */
box-sizing: border-box;
}
.sidebar {
@ -28,3 +30,10 @@ html, body {
.mat-list-item:hover {
background-color: #2a2a40;
}
/* Asegurar que el contenido principal tenga el espacio correcto */
.content {
padding-top: 0; /* El padding ya está en el container */
height: calc(100vh - 65px); /* Altura total menos el header */
overflow: auto;
}

View File

@ -30,6 +30,18 @@ export class TranslationService {
return COMMAND_TYPES['partition-and-format'][this.currentLang];
case 'run-script':
return COMMAND_TYPES['run-script'][this.currentLang];
case 'login':
return COMMAND_TYPES.login[this.currentLang];
case 'software-inventory':
return COMMAND_TYPES['software-inventory'][this.currentLang];
case 'hardware-inventory':
return COMMAND_TYPES['hardware-inventory'][this.currentLang];
case 'rename-image':
return COMMAND_TYPES['rename-image'][this.currentLang];
case 'transfer-image':
return COMMAND_TYPES['transfer-image'][this.currentLang];
case 'kill-job':
return COMMAND_TYPES['kill-job'][this.currentLang];
default:
return command;
}

View File

@ -42,5 +42,35 @@ export const COMMAND_TYPES: any = {
'run-script': {
en: 'Run Script',
es: 'Ejecutar Script'
},
login: {
en: 'Login',
es: 'Iniciar sesión'
},
'software-inventory': {
en: 'Software Inventory',
es: 'Inventario de Software'
},
'hardware-inventory': {
en: 'Hardware Inventory',
es: 'Inventario de Hardware'
},
'rename-image': {
en: 'Rename Image',
es: 'Renombrar Imagen'
},
'transfer-image': {
en: 'Transfer Image',
es: 'Transferir Imagen'
},
'kill-job': {
en: 'Cancel Task',
es: 'Cancelar Tarea'
}
};

View File

@ -0,0 +1,35 @@
.custom-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: white;
}
/* Animaciones adicionales */
.modal-overlay-blur {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.spinner-container {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -0,0 +1,7 @@
<div *ngIf="isVisible" class="modal-overlay-blur" [ngClass]="variant">
<div class="spinner-container">
<mat-spinner *ngIf="showSpinner" diameter="40"></mat-spinner>
<mat-icon *ngIf="customIcon && !showSpinner" class="custom-icon">{{ customIcon }}</mat-icon>
<p>{{ message }}</p>
</div>
</div>

View File

@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-modal-overlay',
templateUrl: './modal-overlay.component.html',
styleUrls: ['./modal-overlay.component.css']
})
export class ModalOverlayComponent {
@Input() isVisible: boolean = false;
@Input() message: string = 'Procesando...';
@Input() variant: 'default' | 'success' | 'warning' | 'error' = 'default';
@Input() showSpinner: boolean = true;
@Input() customIcon?: string;
}

View File

@ -0,0 +1,54 @@
mat-dialog-content {
font-size: 16px;
margin-bottom: 20px;
color: #555;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 0.5em 1.5em 1.5em 1.5em;
}
.action-button {
background-color: #2196f3;
color: white;
padding: 8px 18px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: transform 0.3s ease;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
.action-button:hover:not(:disabled) {
background-color: #3f51b5;
}
.action-button:disabled {
background-color: #ced0df;
cursor: not-allowed;
}
.ordinary-button {
background-color: #f5f5f5;
color: #333;
padding: 8px 18px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: transform 0.3s ease;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
.ordinary-button:hover:not(:disabled) {
background-color: #e0e0e0;
}
mat-checkbox {
margin-top: 15px;
display: block;
}

View File

@ -0,0 +1,9 @@
<h1 mat-dialog-title>Confirmación</h1>
<div mat-dialog-content>
<p>¿Quieres que se encolen las acciones para cuyos PCs no se pueda ejecutar?</p>
<mat-checkbox [(ngModel)]="shouldQueue">Encolar acciones</mat-checkbox>
</div>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onNoClick()">Cancelar</button>
<button class="action-button" (click)="onYesClick()">Confirmar</button>
</div>

View File

@ -0,0 +1,24 @@
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'app-queue-confirmation-modal',
templateUrl: './queue-confirmation-modal.component.html',
styleUrl: './queue-confirmation-modal.component.css'
})
export class QueueConfirmationModalComponent {
shouldQueue: boolean = false;
constructor(
public dialogRef: MatDialogRef<QueueConfirmationModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
onNoClick(): void {
this.dialogRef.close(false);
}
onYesClick(): void {
this.dialogRef.close(this.shouldQueue);
}
}

View File

@ -0,0 +1,74 @@
/* Posición por defecto (bottom-right) */
.scroll-to-top-button-bottom-right {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-to-top-button-bottom-left {
position: fixed;
bottom: 30px;
left: 30px;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-to-top-button-top-right {
position: fixed;
top: 30px;
right: 30px;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-to-top-button-top-left {
position: fixed;
top: 30px;
left: 30px;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Efectos hover para todas las posiciones */
.scroll-to-top-button-bottom-right:hover,
.scroll-to-top-button-bottom-left:hover,
.scroll-to-top-button-top-right:hover,
.scroll-to-top-button-top-left:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
/* Responsive para dispositivos móviles */
@media (max-width: 768px) {
.scroll-to-top-button-bottom-right,
.scroll-to-top-button-bottom-left {
bottom: 20px;
}
.scroll-to-top-button-bottom-right {
right: 20px;
}
.scroll-to-top-button-bottom-left {
left: 20px;
}
.scroll-to-top-button-top-right,
.scroll-to-top-button-top-left {
top: 20px;
}
.scroll-to-top-button-top-right {
right: 20px;
}
.scroll-to-top-button-top-left {
left: 20px;
}
}

View File

@ -0,0 +1,9 @@
<button
[class]="getPositionClass()"
(click)="scrollToTop()"
mat-fab
color="primary"
[matTooltip]="showTooltip ? tooltipText : ''"
[matTooltipPosition]="tooltipPosition">
<mat-icon>keyboard_arrow_up</mat-icon>
</button>

View File

@ -0,0 +1,61 @@
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
@Component({
selector: 'app-scroll-to-top',
templateUrl: './scroll-to-top.component.html',
styleUrls: ['./scroll-to-top.component.css']
})
export class ScrollToTopComponent implements OnInit, OnDestroy {
@Input() threshold: number = 300;
@Input() targetElement: string = '.header-container';
@Input() position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' = 'bottom-right';
@Input() showTooltip: boolean = true;
@Input() tooltipText: string = 'Volver arriba';
@Input() tooltipPosition: 'left' | 'right' | 'above' | 'below' = 'left';
showScrollToTop: boolean = false;
private scrollListener: (() => void) | undefined;
ngOnInit(): void {
this.setupScrollListener();
}
ngOnDestroy(): void {
if (this.scrollListener) {
window.removeEventListener('scroll', this.scrollListener);
}
}
private setupScrollListener(): void {
this.scrollListener = () => {
this.showScrollToTop = window.scrollY > this.threshold;
};
window.addEventListener('scroll', this.scrollListener);
this.scrollListener();
}
scrollToTop(): void {
try {
const targetElement = document.querySelector(this.targetElement);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
} else {
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
}
} catch (error) {
window.scrollTo(0, 0);
}
}
getPositionClass(): string {
return `scroll-to-top-button-${this.position}`;
}
}

View File

@ -543,5 +543,42 @@
"clientMacOS": "MacOS client",
"clientOgLive": "OGLive client",
"clientWindowsSession": "Windows session client",
"clientWindows": "Windows client"
"clientWindows": "Windows client",
"exportCSV": "Export CSV",
"totalTraces": "Total traces",
"todayTraces": "Executed today",
"successful": "Successful",
"failed": "Failed",
"inProgress": "In progress",
"showAdvanced": "Show advanced",
"hideAdvanced": "Hide advanced",
"fromDate": "From",
"toDate": "To",
"sortBy": "Sort by",
"executionDate": "Execution date",
"command": "Command",
"client": "Client",
"showingResults": "Showing {{from}} to {{to}} of {{total}} results",
"refresh": "Refresh",
"autoRefresh": "Auto-refresh",
"viewInput": "View input",
"viewOutput": "View output",
"deleteTrace": "Delete trace",
"cancelTrace": "Cancel trace",
"enterClientName": "Please enter the client name",
"organizationalUnits": "Organizational Units",
"totalEquipments": "Total Equipment",
"onlineEquipments": "Online Equipment",
"offlineEquipments": "Offline Equipment",
"busyEquipments": "Busy Equipment",
"pending": "Pending",
"cancelled": "Cancelled",
"cancelImageTransmission": "Cancel image transmission",
"success": "Success",
"limpiarAcciones": "Clear actions",
"totalClients": "Total clients",
"offline": "Offline",
"online": "Online",
"busy": "Busy",
"cancelTask": "Cancel task"
}

View File

@ -546,5 +546,45 @@
"clientMacOS": "Cliente MacOS",
"clientOgLive": "Cliente OGLive",
"clientWindowsSession": "Cliente con sesión Windows",
"clientWindows": "Cliente Windows"
"clientWindows": "Cliente Windows",
"colaAcciones": "Cola de acciones",
"exportCSV": "Exportar CSV",
"totalTraces": "Total de trazas",
"todayTraces": "Ejecutadas hoy",
"successful": "Exitoso",
"failed": "Fallido",
"inProgress": "En progreso",
"showAdvanced": "Mostrar avanzados",
"hideAdvanced": "Ocultar avanzados",
"fromDate": "Desde",
"toDate": "Hasta",
"sortBy": "Ordenar por",
"executionDate": "Fecha de ejecución",
"command": "Comando",
"client": "Cliente",
"showingResults": "Mostrando {{from}} a {{to}} de {{total}} resultados",
"refresh": "Actualizar",
"autoRefresh": "Auto-actualizar",
"viewInput": "Ver entrada",
"viewOutput": "Ver salida",
"deleteTrace": "Eliminar traza",
"cancelTrace": "Cancelar traza",
"enterClientName": "Por favor, ingrese el nombre del cliente",
"organizationalUnits": "Unidades Organizacionales",
"totalEquipments": "Total de Equipos",
"onlineEquipments": "Equipos Online",
"offlineEquipments": "Equipos Offline",
"busyEquipments": "Equipos Ocupados",
"organizationalStructure": "Estructura Organizacional",
"pending": "Pendiente",
"cancelled": "Cancelado",
"cancelImageTransmission": "Cancelar transmisión de imagen",
"success": "Exitoso",
"limpiarAcciones": "Limpiar acciones",
"totalClients": "Total de clientes",
"offline": "Offline",
"online": "Online",
"busy": "Ocupado",
"cancelTask": "Cancelar tarea"
}

View File

@ -10,6 +10,58 @@ body {
font-family: Roboto, "Helvetica Neue", sans-serif;
}
/* Estilos globales para asegurar que el botón del sidebar sea visible */
.sidebar-toggle-button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 48px !important;
height: 48px !important;
border-radius: 50% !important;
transition: all 0.3s ease !important;
color: #3f51b5 !important;
background-color: #ff0000 !important; /* Fondo rojo para hacerlo visible */
border: 2px solid #000 !important; /* Borde negro para hacerlo visible */
cursor: pointer !important;
z-index: 1001 !important;
position: relative !important;
opacity: 1 !important;
visibility: visible !important;
}
.sidebar-toggle-button:hover {
background-color: rgba(63, 81, 181, 0.1) !important;
transform: scale(1.05) !important;
box-shadow: 0 2px 8px rgba(63, 81, 181, 0.2) !important;
}
.sidebar-toggle-button mat-icon {
color: #ffffff !important; /* Color blanco para que sea visible sobre el fondo rojo */
font-size: 24px !important;
width: 24px !important;
height: 24px !important;
line-height: 24px !important;
}
/* Asegurar que todos los botones mat-icon-button en el toolbar sean visibles */
mat-toolbar button[mat-icon-button] {
display: flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 48px !important;
height: 48px !important;
border-radius: 50% !important;
transition: all 0.3s ease !important;
color: #3f51b5 !important;
background-color: transparent !important;
border: none !important;
cursor: pointer !important;
z-index: 1001 !important;
position: relative !important;
flex-shrink: 0 !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Clase general para el contenedor de carga */
.loading-container {
@ -101,3 +153,47 @@ body {
gap: 1em;
padding: 1.5em;
}
/* Overlay blur reutilizable para modales */
.modal-overlay-blur {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
color: white;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px); /* Para Safari */
}
.modal-overlay-blur p {
margin-top: 16px;
font-size: 16px;
font-weight: 500;
}
.modal-overlay-blur .spinner-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* Variantes del overlay */
.modal-overlay-blur.success {
background: rgba(40, 167, 69, 0.8);
}
.modal-overlay-blur.warning {
background: rgba(255, 193, 7, 0.8);
}
.modal-overlay-blur.error {
background: rgba(220, 53, 69, 0.8);
}