Files
Alessio d042863a56 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
2025-10-02 01:12:39 +02:00

559 lines
26 KiB
Plaintext

@using Data_Coupler.Services
@using Data_Coupler.Models
@inject IBackupService BackupService
@inject IJSRuntime JSRuntime
@inject ILogger<BackupTab> Logger
<div class="settings-section">
<h4>
<i class="fas fa-download text-primary me-2"></i>
Backup e Ripristino Dati
</h4>
<p class="text-muted">
Gestisci il backup e il ripristino di profili, credenziali, associazioni e schedule.
I backup non includono password o API keys per motivi di sicurezza.
</p>
<div class="row">
<!-- Export Section -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-upload text-success me-2"></i>
Esporta Backup
</h5>
</div>
<div class="card-body">
<p class="card-text">
Crea un backup completo del sistema con tutti i dati configurati.
</p>
<!-- Opzioni Export -->
<div class="mb-3">
<label class="form-label fw-bold">Componenti da includere:</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeProfiles" id="exportProfiles">
<label class="form-check-label" for="exportProfiles">
<i class="fas fa-user-cog me-1"></i> Profili Data Coupler
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeCredentials" id="exportCredentials">
<label class="form-check-label" for="exportCredentials">
<i class="fas fa-key me-1"></i> Credenziali (senza password)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeKeyAssociations" id="exportAssociations">
<label class="form-check-label" for="exportAssociations">
<i class="fas fa-link me-1"></i> Associazioni Chiavi
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeProfileSchedules" id="exportSchedules">
<label class="form-check-label" for="exportSchedules">
<i class="fas fa-clock me-1"></i> Schedule Profili
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="exportOptions.IncludeOnlyActiveRecords" id="exportOnlyActive">
<label class="form-check-label" for="exportOnlyActive">
Solo record attivi
</label>
</div>
</div>
<div class="mb-3">
<label for="exportDescription" class="form-label">Descrizione (opzionale):</label>
<input type="text" class="form-control" id="exportDescription" @bind="exportOptions.Description"
placeholder="Backup settimanale, Configurazione produzione, etc.">
</div>
<div class="mb-3">
<label for="exportCreatedBy" class="form-label">Creato da:</label>
<input type="text" class="form-control" id="exportCreatedBy" @bind="exportOptions.CreatedBy"
placeholder="Nome utente o sistema">
</div>
<button class="btn btn-success" @onclick="ExportBackup" disabled="@isExporting">
@if (isExporting)
{
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<text>Esportazione...</text>
}
else
{
<i class="fas fa-download me-2"></i>
<text>Crea Backup</text>
}
</button>
@if (lastExportResult != null)
{
<div class="mt-3">
<div class="alert alert-@(lastExportResult.Success ? "success" : "danger") alert-sm">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>@(lastExportResult.Success ? "Successo" : "Errore"):</strong>
<div>@lastExportResult.Message</div>
@if (lastExportResult.Success)
{
<small class="text-muted">
Durata: @lastExportResult.Duration.TotalSeconds.ToString("F1")s |
Record: @(lastExportResult.ProcessedCounts.Profiles + lastExportResult.ProcessedCounts.Credentials + lastExportResult.ProcessedCounts.KeyAssociations + lastExportResult.ProcessedCounts.ProfileSchedules)
</small>
}
</div>
<button type="button" class="btn-close btn-close-sm" @onclick="() => lastExportResult = null"></button>
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Import Section -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-download text-primary me-2"></i>
Importa Backup
</h5>
</div>
<div class="card-body">
<p class="card-text">
Ripristina i dati da un file di backup precedentemente creato.
</p>
<!-- File Upload -->
<div class="mb-3">
<label for="backupFile" class="form-label fw-bold">Seleziona file backup:</label>
<InputFile class="form-control" id="backupFile"
accept=".json" OnChange="OnFileSelected" />
<div class="form-text">
Seleziona un file .json di backup di Data Coupler
</div>
</div>
@if (selectedBackupInfo != null)
{
<div class="mb-3">
<div class="card border-info">
<div class="card-header bg-info text-white">
<h6 class="mb-0">
<i class="fas fa-info-circle me-2"></i>
Informazioni Backup
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<strong>Versione:</strong> @selectedBackupInfo.Metadata.Version<br>
<strong>Creato:</strong> @selectedBackupInfo.Metadata.CreatedAt.ToString("dd/MM/yyyy HH:mm")<br>
@if (!string.IsNullOrEmpty(selectedBackupInfo.Metadata.CreatedBy))
{
<strong>Da:</strong> @selectedBackupInfo.Metadata.CreatedBy<br>
}
</div>
<div class="col-6">
<strong>Profili:</strong> @selectedBackupInfo.Profiles.Count<br>
<strong>Credenziali:</strong> @selectedBackupInfo.Credentials.Count<br>
<strong>Associazioni:</strong> @selectedBackupInfo.KeyAssociations.Count<br>
<strong>Schedule:</strong> @selectedBackupInfo.ProfileSchedules.Count<br>
</div>
@if (!string.IsNullOrEmpty(selectedBackupInfo.Metadata.Description))
{
<div class="col-12 mt-2">
<strong>Descrizione:</strong> @selectedBackupInfo.Metadata.Description
</div>
}
</div>
</div>
</div>
</div>
<!-- Opzioni Import -->
<div class="mb-3">
<label class="form-label fw-bold">Componenti da ripristinare:</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreProfiles" id="restoreProfiles">
<label class="form-check-label" for="restoreProfiles">
<i class="fas fa-user-cog me-1"></i> Profili (@selectedBackupInfo.Profiles.Count)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreCredentials" id="restoreCredentials">
<label class="form-check-label" for="restoreCredentials">
<i class="fas fa-key me-1"></i> Credenziali (@selectedBackupInfo.Credentials.Count)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreKeyAssociations" id="restoreAssociations">
<label class="form-check-label" for="restoreAssociations">
<i class="fas fa-link me-1"></i> Associazioni (@selectedBackupInfo.KeyAssociations.Count)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="restoreOptions.RestoreProfileSchedules" id="restoreSchedules">
<label class="form-check-label" for="restoreSchedules">
<i class="fas fa-clock me-1"></i> Schedule (@selectedBackupInfo.ProfileSchedules.Count)
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="restoreOptions.OverwriteExisting" id="overwriteExisting">
<label class="form-check-label" for="overwriteExisting">
<i class="fas fa-exclamation-triangle text-warning me-1"></i>
Sovrascrivi dati esistenti
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="restoreOptions.CreateBackupBeforeRestore" id="createBackupBefore">
<label class="form-check-label" for="createBackupBefore">
<i class="fas fa-shield-alt text-info me-1"></i>
Crea backup di sicurezza prima del ripristino
</label>
</div>
</div>
<div class="mb-3">
<label for="importedBy" class="form-label">Importato da:</label>
<input type="text" class="form-control" id="importedBy" @bind="restoreOptions.ImportedBy"
placeholder="Nome utente">
</div>
}
<button class="btn btn-primary" @onclick="ImportBackup"
disabled="@(isImporting || selectedBackupInfo == null)">
@if (isImporting)
{
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<text>Importazione...</text>
}
else
{
<i class="fas fa-upload me-2"></i>
<text>Importa Backup</text>
}
</button>
@if (lastImportResult != null)
{
<div class="mt-3">
<div class="alert alert-@(lastImportResult.Success ? "success" : "danger") alert-sm">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>@(lastImportResult.Success ? "Successo" : "Errore"):</strong>
<div>@lastImportResult.Message</div>
@if (lastImportResult.Success)
{
<small class="text-muted">
Durata: @lastImportResult.Duration.TotalSeconds.ToString("F1")s |
Record importati: @(lastImportResult.ProcessedCounts.Profiles + lastImportResult.ProcessedCounts.Credentials + lastImportResult.ProcessedCounts.KeyAssociations + lastImportResult.ProcessedCounts.ProfileSchedules)
</small>
}
@if (lastImportResult.Warnings.Any())
{
<div class="mt-2">
<strong>Avvisi:</strong>
<ul class="mb-0">
@foreach (var warning in lastImportResult.Warnings)
{
<li><small>@warning</small></li>
}
</ul>
</div>
}
</div>
<button type="button" class="btn-close btn-close-sm" @onclick="() => lastImportResult = null"></button>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- Backup History Section -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-history text-secondary me-2"></i>
Backup Recenti
</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">
Elenco dei file di backup nella cartella Documenti/DataCoupler/Backups
</p>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshBackupList">
<i class="fas fa-refresh me-1"></i>
Aggiorna
</button>
</div>
@if (recentBackups.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Nome File</th>
<th>Data Creazione</th>
<th>Dimensione</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
@foreach (var backup in recentBackups.Take(10))
{
<tr>
<td>
<i class="fas fa-file-code text-primary me-2"></i>
@backup.Name
</td>
<td>
<small>@backup.CreationTime.ToString("dd/MM/yyyy HH:mm")</small>
</td>
<td>
<small>@FormatFileSize(backup.Length)</small>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary"
@onclick="() => DownloadBackup(backup.FullName)"
title="Scarica">
<i class="fas fa-download"></i>
</button>
<button class="btn btn-outline-info"
@onclick='() => OnShowToast.InvokeAsync(("Funzione non disponibile", "info"))'
title="Carica per import">
<i class="fas fa-upload"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-folder-open text-muted fa-3x mb-3"></i>
<p class="text-muted">Nessun backup trovato</p>
</div>
}
</div>
</div>
</div>
</div>
</div>
@code {
[Parameter] public EventCallback<(string message, string type)> OnShowToast { get; set; }
private BackupOptions exportOptions = new()
{
IncludeProfiles = true,
IncludeCredentials = true,
IncludeKeyAssociations = true,
IncludeProfileSchedules = true,
IncludeOnlyActiveRecords = true
};
private RestoreOptions restoreOptions = new()
{
RestoreProfiles = true,
RestoreCredentials = false, // Default false per sicurezza
RestoreKeyAssociations = true,
RestoreProfileSchedules = true,
OverwriteExisting = false,
CreateBackupBeforeRestore = true
};
private bool isExporting = false;
private bool isImporting = false;
private BackupOperationResult? lastExportResult;
private BackupOperationResult? lastImportResult;
private SystemBackupData? selectedBackupInfo;
private List<FileInfo> recentBackups = new();
protected override async Task OnInitializedAsync()
{
await RefreshBackupList();
}
private async Task ExportBackup()
{
try
{
isExporting = true;
lastExportResult = null;
StateHasChanged();
lastExportResult = await BackupService.ExportBackupAsync(exportOptions);
if (lastExportResult.Success)
{
await OnShowToast.InvokeAsync(($"Backup creato con successo", "success"));
await RefreshBackupList();
}
else
{
await OnShowToast.InvokeAsync(("Errore durante la creazione del backup", "error"));
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore esportazione backup");
await OnShowToast.InvokeAsync(($"Errore: {ex.Message}", "error"));
}
finally
{
isExporting = false;
StateHasChanged();
}
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
try
{
var file = e.File;
if (file != null)
{
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();
selectedBackupInfo = await BackupService.GetBackupInfoAsync(content);
StateHasChanged();
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore lettura file backup");
await OnShowToast.InvokeAsync(($"Errore lettura file: {ex.Message}", "error"));
}
}
private async Task ImportBackup()
{
try
{
isImporting = true;
lastImportResult = null;
StateHasChanged();
if (selectedBackupInfo != null)
{
// Serializza il backup selezionato per l'import
var backupContent = System.Text.Json.JsonSerializer.Serialize(selectedBackupInfo);
lastImportResult = await BackupService.ImportBackupFromJsonAsync(backupContent, restoreOptions);
if (lastImportResult.Success)
{
await OnShowToast.InvokeAsync(("Backup importato con successo", "success"));
}
else
{
await OnShowToast.InvokeAsync(("Errore durante l'importazione", "error"));
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore importazione backup");
await OnShowToast.InvokeAsync(($"Errore: {ex.Message}", "error"));
}
finally
{
isImporting = false;
StateHasChanged();
}
}
private Task RefreshBackupList()
{
try
{
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var backupFolder = Path.Combine(documentsPath, "DataCoupler", "Backups");
if (Directory.Exists(backupFolder))
{
var files = new DirectoryInfo(backupFolder)
.GetFiles("*.json")
.OrderByDescending(f => f.CreationTime)
.ToList();
recentBackups = files;
}
else
{
recentBackups = new List<FileInfo>();
}
StateHasChanged();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Errore refresh lista backup");
}
return Task.CompletedTask;
}
private async Task DownloadBackup(string filePath)
{
try
{
var fileName = Path.GetFileName(filePath);
var bytes = await File.ReadAllBytesAsync(filePath);
var stream = new MemoryStream(bytes);
using var streamRef = new DotNetStreamReference(stream);
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
await OnShowToast.InvokeAsync(($"Download di {fileName} avviato", "info"));
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore download backup");
await OnShowToast.InvokeAsync(($"Errore download: {ex.Message}", "error"));
}
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}