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:
2025-10-02 01:12:39 +02:00
parent b76a6760fb
commit d042863a56
71 changed files with 17860 additions and 144 deletions
+14 -24
View File
@@ -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);
}
+350
View File
@@ -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>
+444
View File
@@ -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);
}
}
+256
View File
@@ -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);
}
}
+229
View File
@@ -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);
}
}
+1
View File
@@ -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) => {