feat: Implementazione completa sistema schedulazione con intervalli personalizzati
- Aggiunto supporto schedulazione con intervalli flessibili (secondi/minuti/ore/giorni/settimane/mesi) - Esteso modello ProfileSchedule con campi IntervalValue e IntervalUnit - Ottimizzato ScheduledJobService per controlli ogni 30s con esecuzione parallela - Implementata interfaccia UI completa con anteprima real-time in italiano - Aggiunta migrazione database AddIntervalSchedulingFields - Implementati metodi calcolo NextExecutionTime per intervalli - Aggiunta gestione tracking anti-duplicati e cleanup automatico - Creata documentazione completa (6 file, 2500+ righe) Modifiche tecniche: - ProfileSchedule.cs: Nuovi campi e metodi CalculateNextInterval/GetScheduleDescription - ScheduledJobService.cs: Ridotto check interval a 30s, aggiunto parallel processing - ProfileScheduleService.cs: Supporto calcolo intervalli in UpdateNextExecutionTimeAsync - Scheduling.razor: Aggiunta sezione UI per configurazione intervalli - Scheduling.razor.cs: Implementato GetIntervalPreview() e gestione stato campi
This commit is contained in:
@@ -1812,36 +1812,25 @@ public partial class DataCoupler : ComponentBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera un hash SHA256 dei dati dei campi sorgente mappati.
|
||||
/// Genera un hash SHA256 dei dati del record passato come parametro.
|
||||
/// Utilizzato per rilevare cambiamenti nei dati e ottimizzare il trasferimento.
|
||||
/// Include anche una signature dei campi mappati per rilevare cambi di configurazione.
|
||||
/// Calcola l'hash SOLO sui campi presenti nel record, in ordine alfabetico.
|
||||
/// </summary>
|
||||
private string GenerateDataHash(Dictionary<string, object> record)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Raccoglie i valori dei campi mappati in ordine alfabetico per garantire consistenza
|
||||
var mappedFields = fieldMappings.Keys.OrderBy(k => k).ToList();
|
||||
var valuesForHash = new List<string>();
|
||||
|
||||
// PRIMO: Aggiungi la signature dei mapping per rilevare cambi di configurazione
|
||||
var mappingSignature = string.Join(",", fieldMappings.OrderBy(m => m.Key).Select(m => $"{m.Key}->{m.Value}"));
|
||||
valuesForHash.Add($"MAPPING_SIGNATURE={mappingSignature}");
|
||||
// Ordina le chiavi alfabeticamente per garantire consistenza
|
||||
var orderedKeys = record.Keys.OrderBy(k => k).ToList();
|
||||
|
||||
// SECONDO: Aggiungi i valori dei dati per ogni campo mappato
|
||||
foreach (var sourceField in mappedFields)
|
||||
// Aggiungi i valori dei dati per ogni campo presente nel record
|
||||
foreach (var key in orderedKeys)
|
||||
{
|
||||
if (record.ContainsKey(sourceField))
|
||||
{
|
||||
var value = record[sourceField];
|
||||
var normalizedValue = value?.ToString()?.Trim() ?? "";
|
||||
valuesForHash.Add($"{sourceField}={normalizedValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Se il campo non è presente nel record, aggiungi una stringa vuota
|
||||
valuesForHash.Add($"{sourceField}=");
|
||||
}
|
||||
var value = record[key];
|
||||
var normalizedValue = value?.ToString()?.Trim() ?? "";
|
||||
valuesForHash.Add($"{key}={normalizedValue}");
|
||||
}
|
||||
|
||||
// Combina tutti i valori in una stringa unica
|
||||
@@ -1855,7 +1844,7 @@ public partial class DataCoupler : ComponentBase
|
||||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combinedData));
|
||||
var hashString = Convert.ToHexString(hashBytes);
|
||||
|
||||
Logger.LogDebug("Hash SHA256 generato: {Hash} (include signature mapping)", hashString);
|
||||
Logger.LogDebug("Hash SHA256 generato: {Hash} per {FieldCount} campi", hashString, orderedKeys.Count);
|
||||
return hashString;
|
||||
}
|
||||
}
|
||||
@@ -2578,7 +2567,8 @@ public partial class DataCoupler : ComponentBase
|
||||
|
||||
// Genera la chiave sorgente e l'hash dei dati per questo record (operazioni locali, thread-safe)
|
||||
var sourceKey = GenerateSourceKey(record);
|
||||
var currentDataHash = GenerateDataHash(record);
|
||||
// ✅ Calcola l'hash SOLO sui dati trasformati/mappati che vengono effettivamente trasferiti
|
||||
var currentDataHash = GenerateDataHash(restData);
|
||||
|
||||
// Analizza le associazioni per capire se aggiornare, creare o saltare
|
||||
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
|
||||
@@ -2738,8 +2728,8 @@ public partial class DataCoupler : ComponentBase
|
||||
if (useRecordAssociations && !string.IsNullOrEmpty(transferResult.EntityId))
|
||||
{
|
||||
// IMPORTANTE: Non awaita qui, solo crea il task per esecuzione parallela
|
||||
// Genera l'hash per questo record per salvarlo nell'associazione
|
||||
var dataHashForAssociation = GenerateDataHash(originalData.originalRecord);
|
||||
// Genera l'hash SOLO sui dati trasformati/mappati che sono stati effettivamente trasferiti
|
||||
var dataHashForAssociation = GenerateDataHash(originalData.transformedData);
|
||||
var associationTask = CreateAssociationAsync(originalData.originalRecord, transferResult.EntityId, originalData.recordNumber, dataHashForAssociation);
|
||||
createAssociationTasks.Add(associationTask);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
@page "/scheduling"
|
||||
@using CredentialManager.Models
|
||||
@using CredentialManager.Services
|
||||
@using Data_Coupler.Services
|
||||
|
||||
<PageTitle>Schedulazione Profili</PageTitle>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3><i class="fas fa-clock"></i> Schedulazione Profili</h3>
|
||||
<div>
|
||||
<a href="/scheduling/history" class="btn btn-outline-info me-2">
|
||||
<i class="fas fa-history"></i> Storico Esecuzioni
|
||||
</a>
|
||||
<button class="btn btn-success" @onclick="ShowCreateModal">
|
||||
<i class="fas fa-plus"></i> Nuova Schedulazione
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (schedules == null)
|
||||
{
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!schedules.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> Nessuna schedulazione configurata.
|
||||
<button class="btn btn-link p-0 ms-2" @onclick="ShowCreateModal">
|
||||
Crea la prima schedulazione
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
@foreach (var schedule in schedules.OrderBy(s => s.NextExecutionTime))
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card h-100 @(schedule.IsEnabled ? "border-success" : "border-secondary")">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-@(schedule.ScheduleType switch { "once" => "clock", "interval" => "redo", "daily" => "calendar-day", "weekly" => "calendar-week", "monthly" => "calendar", _ => "clock" })"></i>
|
||||
@schedule.Name
|
||||
</h6>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><button class="dropdown-item" @onclick="() => ShowEditModal(schedule)">
|
||||
<i class="fas fa-edit"></i> Modifica
|
||||
</button></li>
|
||||
<li><button class="dropdown-item" @onclick="() => ExecuteScheduleManually(schedule.Id)">
|
||||
<i class="fas fa-play"></i> Esegui Ora
|
||||
</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button class="dropdown-item text-danger" @onclick="() => DeleteSchedule(schedule.Id)">
|
||||
<i class="fas fa-trash"></i> Elimina
|
||||
</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-2">
|
||||
<strong>Profilo:</strong> @schedule.Profile?.Name
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(schedule.Description))
|
||||
{
|
||||
<p class="card-text small">@schedule.Description</p>
|
||||
}
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">
|
||||
<strong>Tipo:</strong> @schedule.GetScheduleDescription()
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Prossima esecuzione -->
|
||||
@if (schedule.NextExecutionTime.HasValue)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<small class="text-info">
|
||||
<i class="fas fa-clock"></i>
|
||||
<strong>Prossima esecuzione:</strong><br>
|
||||
@schedule.NextExecutionTime.Value.ToString("dd/MM/yyyy HH:mm")
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Ultima esecuzione -->
|
||||
@if (schedule.LastExecutionTime.HasValue)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-history"></i>
|
||||
<strong>Ultima esecuzione:</strong><br>
|
||||
@schedule.LastExecutionTime.Value.ToString("dd/MM/yyyy HH:mm")
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Status ultima esecuzione -->
|
||||
@if (!string.IsNullOrEmpty(schedule.LastExecutionStatus))
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-@(schedule.LastExecutionStatus switch { "success" => "success", "failed" => "danger", "running" => "primary", _ => "secondary" })">
|
||||
@schedule.LastExecutionStatus.ToUpper()
|
||||
@if (schedule.LastExecutionRecordCount.HasValue)
|
||||
{
|
||||
<text> (@schedule.LastExecutionRecordCount record)</text>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
checked="@schedule.IsEnabled"
|
||||
@onchange="(e) => ToggleScheduleEnabled(schedule.Id, (bool)e.Value!)">
|
||||
<label class="form-check-label small">
|
||||
@(schedule.IsEnabled ? "Attiva" : "Disattivata")
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Esecuzioni: @schedule.ExecutionCount
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per creazione/modifica schedulazione -->
|
||||
<div class="modal fade" id="scheduleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
@(editingSchedule?.Id > 0 ? "Modifica Schedulazione" : "Nuova Schedulazione")
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (editingSchedule != null)
|
||||
{
|
||||
<EditForm Model="editingSchedule" OnValidSubmit="SaveSchedule">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nome *</label>
|
||||
<InputText @bind-Value="editingSchedule.Name" class="form-control" />
|
||||
<ValidationMessage For="() => editingSchedule.Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Profilo da eseguire *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.ProfileId" class="form-select" @onchange="OnProfileSelectionChanged">
|
||||
<option value="0">-- Seleziona Profilo --</option>
|
||||
@if (availableProfiles != null)
|
||||
{
|
||||
@foreach (var profile in availableProfiles.Where(p => p.SourceType != "file"))
|
||||
{
|
||||
<option value="@profile.Id">@profile.Name</option>
|
||||
}
|
||||
}
|
||||
</InputSelect>
|
||||
<ValidationMessage For="() => editingSchedule.ProfileId" />
|
||||
@if (availableProfiles?.Any(p => p.SourceType == "file") == true)
|
||||
{
|
||||
<small class="form-text text-muted">
|
||||
⚠️ I profili con file come sorgente sono esclusi dalle schedulazioni per motivi di sicurezza.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Descrizione</label>
|
||||
<InputTextArea @bind-Value="editingSchedule.Description" class="form-control" rows="2" />
|
||||
</div>
|
||||
|
||||
@* Sezione override database *@
|
||||
@if (selectedProfile != null && (selectedProfile.SourceType == "database" || selectedProfile.DestinationType == "database"))
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-database"></i> Override Database
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@if (selectedProfile.SourceType == "database")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Database Sorgente (Opzionale)</label>
|
||||
<InputText @bind-Value="editingSchedule.SourceDatabaseOverride" class="form-control" />
|
||||
<small class="form-text text-muted">
|
||||
Lascia vuoto per usare il database specificato nella credenziale.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (selectedProfile.DestinationType == "database")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Database Destinazione (Opzionale)</label>
|
||||
<InputText @bind-Value="editingSchedule.DestinationDatabaseOverride" class="form-control" />
|
||||
<small class="form-text text-muted">
|
||||
Lascia vuoto per usare il database specificato nella credenziale.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tipo di Schedulazione *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.ScheduleType" class="form-select" @onchange="OnScheduleTypeChanged">
|
||||
<option value="">-- Seleziona Tipo --</option>
|
||||
<option value="once">Una volta</option>
|
||||
<option value="interval">Intervallo Personalizzato</option>
|
||||
<option value="daily">Giornaliera</option>
|
||||
<option value="weekly">Settimanale</option>
|
||||
<option value="monthly">Mensile</option>
|
||||
</InputSelect>
|
||||
<ValidationMessage For="() => editingSchedule.ScheduleType" />
|
||||
</div>
|
||||
|
||||
@if (editingSchedule.ScheduleType == "once")
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Data e Ora di Esecuzione *</label>
|
||||
<input type="datetime-local" @bind="editingSchedule.ScheduledDateTime" class="form-control" />
|
||||
</div>
|
||||
}
|
||||
else if (editingSchedule.ScheduleType == "interval")
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Intervallo *</label>
|
||||
<InputNumber @bind-Value="editingSchedule.IntervalValue" class="form-control" min="1" />
|
||||
<small class="form-text text-muted">Numero di unità temporali</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Unità di Tempo *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.IntervalUnit" class="form-select">
|
||||
<option value="">-- Seleziona Unità --</option>
|
||||
<option value="seconds">Secondi</option>
|
||||
<option value="minutes">Minuti</option>
|
||||
<option value="hours">Ore</option>
|
||||
<option value="days">Giorni</option>
|
||||
<option value="weeks">Settimane</option>
|
||||
<option value="months">Mesi</option>
|
||||
</InputSelect>
|
||||
<small class="form-text text-muted">Frequenza di ripetizione</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Anteprima:</strong> @GetIntervalPreview()
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(editingSchedule.ScheduleType) && editingSchedule.ScheduleType != "once" && editingSchedule.ScheduleType != "interval")
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ora di Esecuzione *</label>
|
||||
<InputText @bind-Value="editingSchedule.DailyTime" class="form-control" placeholder="HH:mm" />
|
||||
<small class="form-text text-muted">Formato 24 ore (es: 14:30)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (editingSchedule.ScheduleType == "weekly")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Giorno della Settimana *</label>
|
||||
<InputSelect @bind-Value="editingSchedule.DayOfWeek" class="form-select">
|
||||
<option value="">-- Seleziona --</option>
|
||||
<option value="0">Domenica</option>
|
||||
<option value="1">Lunedì</option>
|
||||
<option value="2">Martedì</option>
|
||||
<option value="3">Mercoledì</option>
|
||||
<option value="4">Giovedì</option>
|
||||
<option value="5">Venerdì</option>
|
||||
<option value="6">Sabato</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (editingSchedule.ScheduleType == "monthly")
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Giorno del Mese *</label>
|
||||
<InputNumber @bind-Value="editingSchedule.DayOfMonth" class="form-control" min="1" max="31" />
|
||||
<small class="form-text text-muted">1-31</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<InputCheckbox @bind-Value="editingSchedule.IsEnabled" class="form-check-input" />
|
||||
<label class="form-check-label">
|
||||
Schedulazione attiva
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Salva
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,444 @@
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Data_Coupler.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Data_Coupler.Pages;
|
||||
|
||||
public partial class Scheduling : ComponentBase
|
||||
{
|
||||
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
|
||||
[Inject] private IDataCouplerProfileService ProfileService { get; set; } = null!;
|
||||
[Inject] private IDataTransferService DataTransferService { get; set; } = null!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
|
||||
[Inject] private ILogger<Scheduling> Logger { get; set; } = null!;
|
||||
|
||||
protected List<ProfileSchedule>? schedules;
|
||||
protected List<DataCouplerProfile>? availableProfiles;
|
||||
protected ProfileSchedule? editingSchedule;
|
||||
protected DataCouplerProfile? selectedProfile;
|
||||
protected bool isExecuting = false;
|
||||
protected List<ScheduleExecutionHistory>? executionHistory;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadSchedules();
|
||||
await LoadAvailableProfiles();
|
||||
}
|
||||
|
||||
protected async Task LoadSchedules()
|
||||
{
|
||||
try
|
||||
{
|
||||
schedules = await ScheduleService.GetAllSchedulesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento delle schedulazioni");
|
||||
await ShowErrorMessage("Errore nel caricamento delle schedulazioni: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task LoadAvailableProfiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
availableProfiles = await ScheduleService.GetAvailableProfilesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento dei profili disponibili");
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ShowCreateModal()
|
||||
{
|
||||
editingSchedule = new ProfileSchedule
|
||||
{
|
||||
Name = "",
|
||||
IsEnabled = true,
|
||||
ScheduleType = "",
|
||||
DailyTime = "09:00",
|
||||
IntervalValue = 5,
|
||||
IntervalUnit = "minutes"
|
||||
};
|
||||
|
||||
selectedProfile = null;
|
||||
await ShowModal();
|
||||
}
|
||||
|
||||
protected async Task ShowEditModal(ProfileSchedule schedule)
|
||||
{
|
||||
editingSchedule = new ProfileSchedule
|
||||
{
|
||||
Id = schedule.Id,
|
||||
Name = schedule.Name,
|
||||
Description = schedule.Description,
|
||||
ProfileId = schedule.ProfileId,
|
||||
IsEnabled = schedule.IsEnabled,
|
||||
ScheduleType = schedule.ScheduleType,
|
||||
ScheduledDateTime = schedule.ScheduledDateTime,
|
||||
DailyTime = schedule.DailyTime,
|
||||
DayOfWeek = schedule.DayOfWeek,
|
||||
DayOfMonth = schedule.DayOfMonth,
|
||||
IntervalValue = schedule.IntervalValue,
|
||||
IntervalUnit = schedule.IntervalUnit,
|
||||
SourceDatabaseOverride = schedule.SourceDatabaseOverride,
|
||||
DestinationDatabaseOverride = schedule.DestinationDatabaseOverride
|
||||
};
|
||||
|
||||
// Imposta il profilo selezionato per mostrare i campi di override
|
||||
selectedProfile = availableProfiles?.FirstOrDefault(p => p.Id == schedule.ProfileId);
|
||||
|
||||
await ShowModal();
|
||||
}
|
||||
|
||||
protected async Task ShowModal()
|
||||
{
|
||||
StateHasChanged();
|
||||
await Task.Delay(100);
|
||||
|
||||
try
|
||||
{
|
||||
// Proviamo prima con l'approccio Bootstrap standard
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modal = new bootstrap.Modal(document.getElementById('scheduleModal')); " +
|
||||
"modal.show(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('scheduleModal').style.display = 'block'; " +
|
||||
"document.getElementById('scheduleModal').classList.add('show'); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: mostra il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('scheduleModal');" +
|
||||
"modal.style.display = 'block';" +
|
||||
"modal.classList.add('show');" +
|
||||
"document.body.classList.add('modal-open');" +
|
||||
"var backdrop = document.createElement('div');" +
|
||||
"backdrop.className = 'modal-backdrop fade show';" +
|
||||
"document.body.appendChild(backdrop);");
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task HideModal()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modalElement = document.getElementById('scheduleModal'); " +
|
||||
"var modal = bootstrap.Modal.getInstance(modalElement); " +
|
||||
"if (modal) modal.hide(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('scheduleModal').style.display = 'none'; " +
|
||||
"document.getElementById('scheduleModal').classList.remove('show'); " +
|
||||
"document.body.classList.remove('modal-open'); " +
|
||||
"var backdrop = document.querySelector('.modal-backdrop'); " +
|
||||
"if (backdrop) backdrop.remove(); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: nascondi il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('scheduleModal');" +
|
||||
"modal.style.display = 'none';" +
|
||||
"modal.classList.remove('show');" +
|
||||
"document.body.classList.remove('modal-open');" +
|
||||
"var backdrop = document.querySelector('.modal-backdrop');" +
|
||||
"if (backdrop) backdrop.remove();");
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SaveSchedule()
|
||||
{
|
||||
if (editingSchedule == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (editingSchedule.Id == 0)
|
||||
{
|
||||
await ScheduleService.CreateScheduleAsync(editingSchedule);
|
||||
await ShowSuccessMessage("Schedulazione creata con successo!");
|
||||
}
|
||||
else
|
||||
{
|
||||
await ScheduleService.UpdateScheduleAsync(editingSchedule);
|
||||
await ShowSuccessMessage("Schedulazione aggiornata con successo!");
|
||||
}
|
||||
|
||||
await CloseModal();
|
||||
await LoadSchedules();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel salvataggio della schedulazione");
|
||||
await ShowErrorMessage("Errore nel salvataggio: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task DeleteSchedule(int scheduleId)
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Sei sicuro di voler eliminare questa schedulazione?");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
var success = await ScheduleService.DeleteScheduleAsync(scheduleId);
|
||||
if (success)
|
||||
{
|
||||
await ShowSuccessMessage("Schedulazione eliminata con successo!");
|
||||
await LoadSchedules();
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowErrorMessage("Schedulazione non trovata.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nell'eliminazione della schedulazione {ScheduleId}", scheduleId);
|
||||
await ShowErrorMessage("Errore nell'eliminazione: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ToggleScheduleEnabled(int scheduleId, bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedule = schedules?.FirstOrDefault(s => s.Id == scheduleId);
|
||||
if (schedule != null)
|
||||
{
|
||||
schedule.IsEnabled = enabled;
|
||||
await ScheduleService.UpdateScheduleAsync(schedule);
|
||||
|
||||
Logger.LogInformation("Schedulazione {ScheduleId} {Status}", scheduleId, enabled ? "attivata" : "disattivata");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel cambio stato schedulazione {ScheduleId}", scheduleId);
|
||||
await ShowErrorMessage("Errore nel cambio stato: " + ex.Message);
|
||||
await LoadSchedules(); // Ricarica per ripristinare lo stato
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ExecuteScheduleManually(int scheduleId)
|
||||
{
|
||||
if (isExecuting) return;
|
||||
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Eseguire immediatamente questa schedulazione?");
|
||||
if (!confirmed) return;
|
||||
|
||||
isExecuting = true;
|
||||
ScheduleExecutionHistory? executionHistory = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var schedule = schedules?.FirstOrDefault(s => s.Id == scheduleId);
|
||||
if (schedule?.Profile == null)
|
||||
{
|
||||
await ShowErrorMessage("Schedulazione o profilo non trovato.");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("Esecuzione manuale schedulazione {ScheduleId} - {ScheduleName}", scheduleId, schedule.Name);
|
||||
|
||||
// Crea record nello storico
|
||||
executionHistory = new ScheduleExecutionHistory
|
||||
{
|
||||
ScheduleId = scheduleId,
|
||||
ProfileId = schedule.ProfileId,
|
||||
ProfileName = schedule.Profile.Name,
|
||||
StartTime = DateTime.Now,
|
||||
Status = "running",
|
||||
TriggerType = "manual",
|
||||
TriggeredBy = Environment.UserName,
|
||||
SourceType = schedule.Profile.SourceType,
|
||||
DestinationType = schedule.Profile.DestinationType,
|
||||
SourceInfo = schedule.SourceDatabaseOverride != null ? $"Database Override: {schedule.SourceDatabaseOverride}" : null,
|
||||
DestinationInfo = schedule.DestinationDatabaseOverride != null ? $"Database Override: {schedule.DestinationDatabaseOverride}" : null,
|
||||
Message = "Esecuzione manuale avviata"
|
||||
};
|
||||
|
||||
executionHistory = await ScheduleService.CreateExecutionHistoryAsync(executionHistory);
|
||||
|
||||
// Aggiorna lo status della schedulazione
|
||||
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "running", "Esecuzione manuale avviata");
|
||||
await LoadSchedules();
|
||||
|
||||
// Esegui il trasferimento dati con override database se specificati
|
||||
var result = await DataTransferService.ExecuteProfileAsync(
|
||||
schedule.Profile,
|
||||
schedule.SourceDatabaseOverride,
|
||||
schedule.DestinationDatabaseOverride);
|
||||
|
||||
// Aggiorna lo storico con il risultato
|
||||
executionHistory.EndTime = DateTime.Now;
|
||||
executionHistory.Status = result.IsSuccess ? "success" : "failed";
|
||||
executionHistory.RecordsProcessed = result.RecordsProcessed;
|
||||
executionHistory.Message = result.IsSuccess
|
||||
? $"Esecuzione completata con successo. {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi."
|
||||
: $"Esecuzione fallita: {result.ErrorMessage}";
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
executionHistory.ErrorDetails = string.Join(Environment.NewLine, result.ErrorDetails);
|
||||
}
|
||||
|
||||
// Aggiungi informazioni aggiuntive se disponibili
|
||||
if (result.AdditionalInfo.Any())
|
||||
{
|
||||
executionHistory.AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(result.AdditionalInfo);
|
||||
}
|
||||
|
||||
await ScheduleService.UpdateExecutionHistoryAsync(executionHistory);
|
||||
|
||||
// Aggiorna lo status della schedulazione
|
||||
var status = result.IsSuccess ? "success" : "failed";
|
||||
var message = result.IsSuccess
|
||||
? $"Esecuzione completata con successo. {result.RecordsProcessed} record elaborati."
|
||||
: $"Esecuzione fallita: {result.ErrorMessage}";
|
||||
|
||||
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
await LoadSchedules();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId);
|
||||
|
||||
// Aggiorna lo storico in caso di eccezione
|
||||
if (executionHistory != null)
|
||||
{
|
||||
executionHistory.EndTime = DateTime.Now;
|
||||
executionHistory.Status = "failed";
|
||||
executionHistory.Message = $"Errore durante l'esecuzione: {ex.Message}";
|
||||
executionHistory.ErrorDetails = ex.ToString();
|
||||
await ScheduleService.UpdateExecutionHistoryAsync(executionHistory);
|
||||
}
|
||||
|
||||
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}");
|
||||
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isExecuting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnProfileSelectionChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (editingSchedule != null && int.TryParse(e.Value?.ToString(), out int profileId))
|
||||
{
|
||||
editingSchedule.ProfileId = profileId;
|
||||
selectedProfile = availableProfiles?.FirstOrDefault(p => p.Id == profileId);
|
||||
|
||||
// Reset override database quando cambia profilo
|
||||
editingSchedule.SourceDatabaseOverride = null;
|
||||
editingSchedule.DestinationDatabaseOverride = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedProfile = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void OnScheduleTypeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (editingSchedule != null)
|
||||
{
|
||||
editingSchedule.ScheduleType = e.Value?.ToString() ?? "";
|
||||
|
||||
// Reset campi quando cambia il tipo
|
||||
if (editingSchedule.ScheduleType != "once")
|
||||
{
|
||||
editingSchedule.ScheduledDateTime = null;
|
||||
}
|
||||
|
||||
if (editingSchedule.ScheduleType != "weekly")
|
||||
{
|
||||
editingSchedule.DayOfWeek = null;
|
||||
}
|
||||
|
||||
if (editingSchedule.ScheduleType != "monthly")
|
||||
{
|
||||
editingSchedule.DayOfMonth = null;
|
||||
}
|
||||
|
||||
if (editingSchedule.ScheduleType != "interval")
|
||||
{
|
||||
editingSchedule.IntervalValue = null;
|
||||
editingSchedule.IntervalUnit = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Imposta valori predefiniti per interval
|
||||
editingSchedule.IntervalValue ??= 5;
|
||||
editingSchedule.IntervalUnit ??= "minutes";
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected string GetIntervalPreview()
|
||||
{
|
||||
if (editingSchedule == null || !editingSchedule.IntervalValue.HasValue || string.IsNullOrEmpty(editingSchedule.IntervalUnit))
|
||||
{
|
||||
return "Configura intervallo e unità di tempo";
|
||||
}
|
||||
|
||||
var value = editingSchedule.IntervalValue.Value;
|
||||
var unit = editingSchedule.IntervalUnit;
|
||||
|
||||
var unitName = unit switch
|
||||
{
|
||||
"seconds" => value == 1 ? "secondo" : "secondi",
|
||||
"minutes" => value == 1 ? "minuto" : "minuti",
|
||||
"hours" => value == 1 ? "ora" : "ore",
|
||||
"days" => value == 1 ? "giorno" : "giorni",
|
||||
"weeks" => value == 1 ? "settimana" : "settimane",
|
||||
"months" => value == 1 ? "mese" : "mesi",
|
||||
_ => unit
|
||||
};
|
||||
|
||||
return $"Esecuzione ogni {value} {unitName}";
|
||||
}
|
||||
|
||||
private async Task CloseModal()
|
||||
{
|
||||
await HideModal();
|
||||
editingSchedule = null;
|
||||
selectedProfile = null;
|
||||
}
|
||||
|
||||
private async Task ShowSuccessMessage(string message)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", message);
|
||||
}
|
||||
|
||||
private async Task ShowErrorMessage(string message)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Errore: " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
@page "/scheduling/history"
|
||||
@using CredentialManager.Models
|
||||
@using CredentialManager.Services
|
||||
|
||||
<PageTitle>Storico Esecuzioni</PageTitle>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h3><i class="fas fa-history"></i> Storico Esecuzioni Schedulazioni</h3>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary me-2" @onclick="LoadHistory">
|
||||
<i class="fas fa-sync-alt"></i> Aggiorna
|
||||
</button>
|
||||
<a href="/scheduling" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left"></i> Torna alle Schedulazioni
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (executionHistory == null)
|
||||
{
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!executionHistory.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> Nessuna esecuzione trovata nello storico.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-chart-bar"></i> Statistiche
|
||||
</h6>
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="fs-5 text-success">@executionHistory.Count(e => e.Status == "success")</div>
|
||||
<small class="text-muted">Successo</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="fs-5 text-danger">@executionHistory.Count(e => e.Status == "failed")</div>
|
||||
<small class="text-muted">Fallite</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="fs-5 text-primary">@executionHistory.Count(e => e.Status == "running")</div>
|
||||
<small class="text-muted">In corso</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-database"></i> Record Processati
|
||||
</h6>
|
||||
<div class="text-center">
|
||||
<div class="fs-4 text-info">@executionHistory.Where(e => e.Status == "success").Sum(e => e.RecordsProcessed)</div>
|
||||
<small class="text-muted">Totale record elaborati con successo</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Data/Ora Inizio</th>
|
||||
<th>Profilo</th>
|
||||
<th>Schedulazione</th>
|
||||
<th>Durata</th>
|
||||
<th>Status</th>
|
||||
<th>Record</th>
|
||||
<th>Trigger</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var execution in executionHistory.OrderByDescending(e => e.StartTime))
|
||||
{
|
||||
<tr class="@(execution.Status == "success" ? "table-success" : execution.Status == "failed" ? "table-danger" : execution.Status == "running" ? "table-info" : "")">
|
||||
<td>
|
||||
<div>@execution.StartTime.ToString("dd/MM/yyyy HH:mm:ss")</div>
|
||||
@if (execution.EndTime.HasValue)
|
||||
{
|
||||
<small class="text-muted">Fine: @execution.EndTime.Value.ToString("HH:mm:ss")</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">@execution.ProfileName</div>
|
||||
@if (!string.IsNullOrEmpty(execution.SourceInfo) || !string.IsNullOrEmpty(execution.DestinationInfo))
|
||||
{
|
||||
<small class="text-muted">
|
||||
@if (!string.IsNullOrEmpty(execution.SourceInfo))
|
||||
{
|
||||
<div>S: @execution.SourceInfo</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(execution.DestinationInfo))
|
||||
{
|
||||
<div>D: @execution.DestinationInfo</div>
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>@execution.Schedule?.Name</div>
|
||||
<small class="text-muted">ID: @execution.ScheduleId</small>
|
||||
</td>
|
||||
<td>
|
||||
@if (execution.Duration.HasValue)
|
||||
{
|
||||
<span class="badge bg-secondary">@FormatDuration(execution.Duration.Value)</span>
|
||||
}
|
||||
else if (execution.Status == "running")
|
||||
{
|
||||
<span class="badge bg-primary">In corso...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-@(execution.Status switch { "success" => "success", "failed" => "danger", "running" => "primary", "cancelled" => "warning", _ => "secondary" })">
|
||||
@execution.GetStatusDisplayText()
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold">@execution.RecordsProcessed</div>
|
||||
@if (execution.RecordsWithErrors.HasValue && execution.RecordsWithErrors.Value > 0)
|
||||
{
|
||||
<small class="text-warning">@execution.RecordsWithErrors errori</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<span class="badge bg-@(execution.TriggerType == "manual" ? "info" : "success")">
|
||||
@execution.TriggerType.ToUpper()
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(execution.TriggeredBy))
|
||||
{
|
||||
<small class="text-muted">@execution.TriggeredBy</small>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info" @onclick="() => ShowExecutionDetails(execution)">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per dettagli esecuzione -->
|
||||
<div class="modal fade" id="executionDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-info-circle"></i> Dettagli Esecuzione
|
||||
@if (selectedExecution != null)
|
||||
{
|
||||
<span> - @selectedExecution.ProfileName</span>
|
||||
}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (selectedExecution != null)
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Informazioni Generali</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><th>Data Inizio:</th><td>@selectedExecution.StartTime.ToString("dd/MM/yyyy HH:mm:ss")</td></tr>
|
||||
<tr><th>Data Fine:</th><td>@(selectedExecution.EndTime?.ToString("dd/MM/yyyy HH:mm:ss") ?? "In corso")</td></tr>
|
||||
<tr><th>Durata:</th><td>@(selectedExecution.Duration?.ToString(@"hh\:mm\:ss") ?? "N/A")</td></tr>
|
||||
<tr><th>Status:</th><td><span class="badge bg-@(selectedExecution.Status switch { "success" => "success", "failed" => "danger", "running" => "primary", "cancelled" => "warning", _ => "secondary" })">@selectedExecution.GetStatusDisplayText()</span></td></tr>
|
||||
<tr><th>Trigger:</th><td>@selectedExecution.TriggerType (@selectedExecution.TriggeredBy)</td></tr>
|
||||
<tr><th>Record Processati:</th><td>@selectedExecution.RecordsProcessed</td></tr>
|
||||
@if (selectedExecution.RecordsWithErrors.HasValue)
|
||||
{
|
||||
<tr><th>Record con Errori:</th><td class="text-warning">@selectedExecution.RecordsWithErrors</td></tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Configurazione</h6>
|
||||
<table class="table table-sm">
|
||||
<tr><th>Schedulazione:</th><td>@selectedExecution.Schedule?.Name</td></tr>
|
||||
<tr><th>Profilo:</th><td>@selectedExecution.ProfileName (ID: @selectedExecution.ProfileId)</td></tr>
|
||||
<tr><th>Tipo Sorgente:</th><td>@selectedExecution.SourceType</td></tr>
|
||||
<tr><th>Tipo Destinazione:</th><td>@selectedExecution.DestinationType</td></tr>
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.SourceInfo))
|
||||
{
|
||||
<tr><th>Info Sorgente:</th><td>@selectedExecution.SourceInfo</td></tr>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.DestinationInfo))
|
||||
{
|
||||
<tr><th>Info Destinazione:</th><td>@selectedExecution.DestinationInfo</td></tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.Message))
|
||||
{
|
||||
<h6>Messaggio</h6>
|
||||
<div class="alert alert-@(selectedExecution.Status == "success" ? "success" : selectedExecution.Status == "failed" ? "danger" : "info")">
|
||||
@selectedExecution.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.ErrorDetails))
|
||||
{
|
||||
<h6>Dettagli Errori</h6>
|
||||
<div class="alert alert-danger">
|
||||
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(selectedExecution.AdditionalInfo))
|
||||
{
|
||||
<h6>Informazioni Aggiuntive</h6>
|
||||
<div class="alert alert-light">
|
||||
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.AdditionalInfo</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Chiudi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
using CredentialManager.Models;
|
||||
using CredentialManager.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Data_Coupler.Pages;
|
||||
|
||||
public partial class SchedulingHistory : ComponentBase
|
||||
{
|
||||
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
|
||||
[Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!;
|
||||
|
||||
protected List<ScheduleExecutionHistory>? executionHistory;
|
||||
protected ScheduleExecutionHistory? selectedExecution;
|
||||
protected bool isLoading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadHistory();
|
||||
}
|
||||
|
||||
protected async Task LoadHistory()
|
||||
{
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Carica le ultime 100 esecuzioni
|
||||
executionHistory = await ScheduleService.GetRecentExecutionsAsync(100);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento dello storico delle esecuzioni");
|
||||
await ShowErrorMessage("Errore nel caricamento dello storico: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ShowExecutionDetails(ScheduleExecutionHistory execution)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Carica i dettagli completi dell'esecuzione
|
||||
selectedExecution = await ScheduleService.GetExecutionByIdAsync(execution.Id);
|
||||
|
||||
if (selectedExecution != null)
|
||||
{
|
||||
await ShowModal();
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowErrorMessage("Dettagli dell'esecuzione non trovati.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Errore nel caricamento dei dettagli esecuzione {ExecutionId}", execution.Id);
|
||||
await ShowErrorMessage("Errore nel caricamento dei dettagli: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ShowModal()
|
||||
{
|
||||
StateHasChanged();
|
||||
await Task.Delay(100);
|
||||
|
||||
try
|
||||
{
|
||||
// Proviamo prima con l'approccio Bootstrap standard
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modal = new bootstrap.Modal(document.getElementById('executionDetailModal')); " +
|
||||
"modal.show(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('executionDetailModal').style.display = 'block'; " +
|
||||
"document.getElementById('executionDetailModal').classList.add('show'); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: mostra il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('executionDetailModal');" +
|
||||
"modal.style.display = 'block';" +
|
||||
"modal.classList.add('show');" +
|
||||
"document.body.classList.add('modal-open');" +
|
||||
"var backdrop = document.createElement('div');" +
|
||||
"backdrop.className = 'modal-backdrop fade show';" +
|
||||
"document.body.appendChild(backdrop);");
|
||||
}
|
||||
}
|
||||
|
||||
protected string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalHours >= 1)
|
||||
{
|
||||
return duration.ToString(@"h\:mm\:ss");
|
||||
}
|
||||
else if (duration.TotalMinutes >= 1)
|
||||
{
|
||||
return duration.ToString(@"m\:ss");
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"{duration.TotalSeconds:F1}s";
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task HideModal()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { " +
|
||||
"var modalElement = document.getElementById('executionDetailModal'); " +
|
||||
"var modal = bootstrap.Modal.getInstance(modalElement); " +
|
||||
"if (modal) modal.hide(); " +
|
||||
"} else { " +
|
||||
"document.getElementById('executionDetailModal').style.display = 'none'; " +
|
||||
"document.getElementById('executionDetailModal').classList.remove('show'); " +
|
||||
"document.body.classList.remove('modal-open'); " +
|
||||
"var backdrop = document.querySelector('.modal-backdrop'); " +
|
||||
"if (backdrop) backdrop.remove(); " +
|
||||
"}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback: nascondi il modal manualmente
|
||||
await JSRuntime.InvokeVoidAsync("eval",
|
||||
"var modal = document.getElementById('executionDetailModal');" +
|
||||
"modal.style.display = 'none';" +
|
||||
"modal.classList.remove('show');" +
|
||||
"document.body.classList.remove('modal-open');" +
|
||||
"var backdrop = document.querySelector('.modal-backdrop');" +
|
||||
"if (backdrop) backdrop.remove();");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowErrorMessage(string message)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("alert", "Errore: " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
@page "/settings"
|
||||
@using Data_Coupler.Services
|
||||
@using Data_Coupler.Models
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<SettingsPage> Logger
|
||||
|
||||
<PageTitle>Impostazioni - Data Coupler</PageTitle>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>
|
||||
<i class="fas fa-cog text-primary"></i>
|
||||
Impostazioni Sistema
|
||||
</h1>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Impostazioni</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(toastMessage))
|
||||
{
|
||||
<div class="alert alert-@(toastType == "success" ? "success" : toastType == "error" ? "danger" : "info") alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-@(toastType == "success" ? "check-circle" : toastType == "error" ? "exclamation-circle" : "info-circle")"></i>
|
||||
@toastMessage
|
||||
<button type="button" class="btn-close" @onclick="ClearToast" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<ul class="nav nav-tabs mb-4" id="settingsTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "backup" ? "active" : "")"
|
||||
id="backup-tab"
|
||||
@onclick='() => SetActiveTab("backup")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="backup"
|
||||
aria-selected="@(activeTab == "backup")">
|
||||
<i class="fas fa-download me-2"></i>
|
||||
Backup e Ripristino
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "system" ? "active" : "")"
|
||||
id="system-tab"
|
||||
@onclick='() => SetActiveTab("system")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="system"
|
||||
aria-selected="@(activeTab == "system")">
|
||||
<i class="fas fa-server me-2"></i>
|
||||
Sistema
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "security" ? "active" : "")"
|
||||
id="security-tab"
|
||||
@onclick='() => SetActiveTab("security")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="security"
|
||||
aria-selected="@(activeTab == "security")">
|
||||
<i class="fas fa-shield-alt me-2"></i>
|
||||
Sicurezza
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")"
|
||||
id="maintenance-tab"
|
||||
@onclick='() => SetActiveTab("maintenance")'
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="maintenance"
|
||||
aria-selected="@(activeTab == "maintenance")">
|
||||
<i class="fas fa-tools me-2"></i>
|
||||
Manutenzione
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content" id="settingsTabContent">
|
||||
<!-- Backup Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "backup" ? "show active" : "")"
|
||||
id="backup"
|
||||
role="tabpanel"
|
||||
aria-labelledby="backup-tab">
|
||||
<BackupTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
|
||||
<!-- System Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "system" ? "show active" : "")"
|
||||
id="system"
|
||||
role="tabpanel"
|
||||
aria-labelledby="system-tab">
|
||||
<SystemTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "security" ? "show active" : "")"
|
||||
id="security"
|
||||
role="tabpanel"
|
||||
aria-labelledby="security-tab">
|
||||
<SecurityTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Tab -->
|
||||
<div class="tab-pane fade @(activeTab == "maintenance" ? "show active" : "")"
|
||||
id="maintenance"
|
||||
role="tabpanel"
|
||||
aria-labelledby="maintenance-tab">
|
||||
<MaintenanceTab OnShowToast="ShowToastTuple" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include CSS per migliorare l'aspetto -->
|
||||
<style>
|
||||
.nav-tabs .nav-link {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem 0.375rem 0 0;
|
||||
color: #6c757d;
|
||||
background-color: #f8f9fa;
|
||||
margin-right: 2px;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
border-color: #e9ecef #e9ecef #dee2e6;
|
||||
color: #495057;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border-color: #dee2e6 #dee2e6 #fff;
|
||||
border-bottom: 1px solid #fff;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #fff;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section h4 {
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem 0.5rem 0 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string activeTab = "backup";
|
||||
private string toastMessage = "";
|
||||
private string toastType = "info";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Inizializzazione se necessaria
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void SetActiveTab(string tabName)
|
||||
{
|
||||
activeTab = tabName;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ShowToast(string message, string type = "info")
|
||||
{
|
||||
toastMessage = message;
|
||||
toastType = type;
|
||||
StateHasChanged();
|
||||
|
||||
// Auto-hide dopo 5 secondi per messaggi di successo
|
||||
if (type == "success")
|
||||
{
|
||||
_ = Task.Delay(5000).ContinueWith(_ => ClearToast());
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowToastTuple((string message, string type) toast)
|
||||
{
|
||||
ShowToast(toast.message, toast.type);
|
||||
}
|
||||
|
||||
private void ClearToast()
|
||||
{
|
||||
toastMessage = "";
|
||||
toastType = "info";
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.server.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/site.js"></script>
|
||||
<script>
|
||||
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
|
||||
|
||||
Reference in New Issue
Block a user