Convert Trafag exporter to Blazor Server app with UI and scheduler

This commit is contained in:
2026-04-09 15:52:23 +02:00
parent 8d8b62f1f5
commit ec14b838e5
25 changed files with 1438 additions and 0 deletions
@@ -0,0 +1,117 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService ExportService
<PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<MudStack Row="true" Spacing="2" Class="mb-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@isRunningAll" OnClick="ExportAllAsync">Alle exportieren</MudButton>
<MudText Typo="Typo.body1">Nächster automatischer Lauf: @nextRunText</MudText>
</MudStack>
<MudTable Items="sites" Hover="true" Dense="true">
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Letzter Status</MudTh>
<MudTh>Row Count</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.HanaServer?.Name</MudTd>
<MudTd>@GetStatusIcon(context.Id)</MudTd>
<MudTd>@GetRows(context.Id)</MudTd>
<MudTd>@GetLastRun(context.Id)</MudTd>
<MudTd>@GetDuration(context.Id)</MudTd>
<MudTd>
@if (runningSiteIds.Contains(context.Id))
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
}
else
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => ExportSingleAsync(context.Id)">Einzeln exportieren</MudButton>
}
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<Site> sites = [];
private Dictionary<int, ExportLog?> latestLogs = new();
private HashSet<int> runningSiteIds = [];
private bool isRunningAll;
private string nextRunText = "-";
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
sites = await db.Sites
.Include(x => x.HanaServer)
.Where(x => x.IsActive)
.OrderBy(x => x.Land)
.ToListAsync();
latestLogs = await ExportService.GetLatestLogsPerSiteAsync();
var nextRun = await ExportService.GetNextRunAsync();
nextRunText = nextRun.HasValue ? nextRun.Value.ToString("dd.MM.yyyy HH:mm") : "Deaktiviert";
}
private async Task ExportAllAsync()
{
isRunningAll = true;
foreach (var site in sites)
{
runningSiteIds.Add(site.Id);
}
StateHasChanged();
await ExportService.ExportAllActiveSitesAsync();
runningSiteIds.Clear();
isRunningAll = false;
await LoadAsync();
}
private async Task ExportSingleAsync(int siteId)
{
runningSiteIds.Add(siteId);
StateHasChanged();
await ExportService.ExportSiteAsync(siteId);
runningSiteIds.Remove(siteId);
await LoadAsync();
}
private string GetStatusIcon(int siteId)
{
if (!latestLogs.TryGetValue(siteId, out var log) || log is null)
{
return "-";
}
return log.Status == "OK" ? "✅" : "❌";
}
private string GetRows(int siteId) =>
latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.RowCount.ToString() : "-";
private string GetLastRun(int siteId) =>
latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss") : "-";
private string GetDuration(int siteId) =>
latestLogs.TryGetValue(siteId, out var log) && log is not null ? $"{log.DurationSeconds:F1}s" : "-";
}
@@ -0,0 +1,94 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<AppDbContext> DbFactory
<PageTitle>Logs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Logs</MudText>
<MudGrid Class="mb-4">
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="filterLand" /></MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" Label="Status" @bind-Value="filterStatus">
<MudSelectItem Value="">Alle</MudSelectItem>
<MudSelectItem Value="OK">OK</MudSelectItem>
<MudSelectItem Value="Error">Error</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="3"><MudDatePicker Label="Ab Datum" @bind-Date="filterFromDate" /></MudItem>
<MudItem xs="12" md="3"><MudButton Variant="Variant.Filled" OnClick="LoadAsync">Filtern</MudButton></MudItem>
</MudGrid>
<MudStack Row="true" Spacing="2" Class="mb-2">
<MudNumericField T="int" Label="Logs älter als Tage löschen" @bind-Value="deleteOlderThanDays" Min="1" />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOlderAsync">Logs löschen</MudButton>
</MudStack>
<MudTable Items="logs" Dense="true" Hover="true" RowClassFunc="GetRowClass">
<HeaderContent>
<MudTh>Timestamp</MudTh>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Status</MudTh>
<MudTh>Rows</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Fehler</MudTh>
<MudTh>Dateiname</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Status</MudTd>
<MudTd>@context.RowCount</MudTd>
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
<MudTd>@context.ErrorMessage</MudTd>
<MudTd>@context.FileName</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<ExportLog> logs = [];
private string filterLand = string.Empty;
private string filterStatus = string.Empty;
private DateTime? filterFromDate;
private int deleteOlderThanDays = 30;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var query = db.ExportLogs.AsQueryable();
if (!string.IsNullOrWhiteSpace(filterLand))
{
query = query.Where(x => x.Land.Contains(filterLand));
}
if (!string.IsNullOrWhiteSpace(filterStatus))
{
query = query.Where(x => x.Status == filterStatus);
}
if (filterFromDate.HasValue)
{
var fromUtc = filterFromDate.Value.Date.ToUniversalTime();
query = query.Where(x => x.Timestamp >= fromUtc);
}
logs = await query.OrderByDescending(x => x.Timestamp).ToListAsync();
}
private string GetRowClass(ExportLog log, int _) => log.Status == "Error" ? "mud-theme-error" : string.Empty;
private async Task DeleteOlderAsync()
{
var threshold = DateTime.UtcNow.AddDays(-deleteOlderThanDays);
await using var db = await DbFactory.CreateDbContextAsync();
var oldLogs = await db.ExportLogs.Where(x => x.Timestamp < threshold).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
await db.SaveChangesAsync();
await LoadAsync();
}
}
@@ -0,0 +1,93 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<AppDbContext> DbFactory
@inject CryptoService CryptoService
@inject SharePointUploadService SharePointUploadService
<PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
<MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h6">SharePoint</MudText>
<MudGrid>
<MudItem xs="12" md="6"><MudTextField Label="SiteUrl" @bind-Value="sharePointConfig.SiteUrl" /></MudItem>
<MudItem xs="12" md="6"><MudTextField Label="ExportFolder" @bind-Value="sharePointConfig.ExportFolder" /></MudItem>
<MudItem xs="12" md="4"><MudTextField Label="TenantId" @bind-Value="sharePointConfig.TenantId" /></MudItem>
<MudItem xs="12" md="4"><MudTextField Label="ClientId" @bind-Value="sharePointConfig.ClientId" /></MudItem>
<MudItem xs="12" md="4"><MudTextField Label="ClientSecret" InputType="InputType.Password" @bind-Value="sharePointClientSecret" /></MudItem>
</MudGrid>
<MudStack Row="true" Spacing="2" Class="mt-3">
<MudButton Variant="Variant.Filled" OnClick="SaveAsync">Speichern</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="TestSharePointAsync">SharePoint Verbindung testen</MudButton>
</MudStack>
</MudPaper>
<MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h6">Export & Timer</MudText>
<MudGrid>
<MudItem xs="12" md="3"><MudTextField Label="DateFilter" @bind-Value="settings.DateFilter" /></MudItem>
<MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerHour" Min="0" Max="23" @bind-Value="settings.TimerHour" /></MudItem>
<MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerMinute" Min="0" Max="59" @bind-Value="settings.TimerMinute" /></MudItem>
<MudItem xs="12" md="2"><MudCheckBox Label="TimerEnabled" @bind-Value="settings.TimerEnabled" /></MudItem>
</MudGrid>
<MudText Typo="Typo.body2" Class="mt-3">Dateiname-Vorschau: @PreviewFileName</MudText>
</MudPaper>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">@message</MudAlert>
@code {
private SharePointConfig sharePointConfig = new();
private ExportSettings settings = new();
private string sharePointClientSecret = string.Empty;
private string message = "Bereit.";
private string PreviewFileName => $"Sales_{{TSC}}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
sharePointConfig = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync();
settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync();
sharePointClientSecret = CryptoService.Decrypt(sharePointConfig.EncryptedClientSecret);
}
private async Task SaveAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var sp = await db.SharePointConfigs.SingleAsync(x => x.Id == sharePointConfig.Id);
var es = await db.ExportSettings.SingleAsync(x => x.Id == settings.Id);
sp.SiteUrl = sharePointConfig.SiteUrl;
sp.ExportFolder = sharePointConfig.ExportFolder;
sp.TenantId = sharePointConfig.TenantId;
sp.ClientId = sharePointConfig.ClientId;
sp.EncryptedClientSecret = CryptoService.Encrypt(sharePointClientSecret);
es.DateFilter = settings.DateFilter;
es.TimerHour = settings.TimerHour;
es.TimerMinute = settings.TimerMinute;
es.TimerEnabled = settings.TimerEnabled;
await db.SaveChangesAsync();
message = "Settings gespeichert.";
}
private async Task TestSharePointAsync()
{
try
{
var ok = await SharePointUploadService.TestConnectionAsync(
sharePointConfig.SiteUrl,
sharePointConfig.TenantId,
sharePointConfig.ClientId,
sharePointClientSecret);
message = ok ? "SharePoint Verbindung OK." : "SharePoint Verbindung fehlgeschlagen.";
}
catch (Exception ex)
{
message = $"SharePoint Test fehlgeschlagen: {ex.Message}";
}
}
}
@@ -0,0 +1,215 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<AppDbContext> DbFactory
@inject HanaQueryService HanaQueryService
@inject CryptoService CryptoService
<PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h6">Neuen Standort hinzufügen</MudText>
<MudGrid>
<MudItem xs="12" md="3"><MudSelect T="int" Label="Server" @bind-Value="newSite.HanaServerId">@foreach (var srv in servers) { <MudSelectItem Value="@srv.Id">@srv.Name</MudSelectItem> }</MudSelect></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="newSite.Schema" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="newSite.TSC" /></MudItem>
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="newSite.Land" /></MudItem>
<MudItem xs="12" md="1"><MudCheckBox Label="Aktiv" @bind-Value="newSite.IsActive" /></MudItem>
<MudItem xs="12" md="1"><MudButton Variant="Variant.Filled" OnClick="AddSiteAsync">Speichern</MudButton></MudItem>
</MudGrid>
</MudPaper>
<MudTable Items="sites" Dense="true" Hover="true" Class="mb-6">
<HeaderContent>
<MudTh>Land</MudTh><MudTh>TSC</MudTh><MudTh>Schema</MudTh><MudTh>Server</MudTh><MudTh>Aktiv</MudTh><MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.HanaServer?.Name</MudTd>
<MudTd>@(context.IsActive ? "Ja" : "Nein")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => EditSite(context)">Edit</MudButton>
<MudButton Size="Size.Small" Color="Color.Error" Variant="Variant.Text" OnClick="() => DeleteSiteAsync(context.Id)">Delete</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
@if (editingSite is not null)
{
<MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h6">Standort bearbeiten</MudText>
<MudGrid>
<MudItem xs="12" md="3"><MudSelect T="int" Label="Server" @bind-Value="editingSite.HanaServerId">@foreach (var srv in servers) { <MudSelectItem Value="@srv.Id">@srv.Name</MudSelectItem> }</MudSelect></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="editingSite.Schema" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="editingSite.TSC" /></MudItem>
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="editingSite.Land" /></MudItem>
<MudItem xs="12" md="1"><MudCheckBox Label="Aktiv" @bind-Value="editingSite.IsActive" /></MudItem>
<MudItem xs="12" md="1"><MudButton Variant="Variant.Filled" OnClick="SaveSiteAsync">Update</MudButton></MudItem>
</MudGrid>
</MudPaper>
}
<MudDivider Class="my-4" />
<MudText Typo="Typo.h5" Class="mb-3">HANA Server</MudText>
<MudPaper Class="pa-4 mb-4">
<MudGrid>
<MudItem xs="12" md="2"><MudTextField Label="Name" @bind-Value="newServer.Name" /></MudItem>
<MudItem xs="12" md="3"><MudTextField Label="Host" @bind-Value="newServer.Host" /></MudItem>
<MudItem xs="12" md="1"><MudNumericField T="int" Label="Port" @bind-Value="newServer.Port" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Username" @bind-Value="newServer.Username" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Password" InputType="InputType.Password" @bind-Value="newServerPassword" /></MudItem>
<MudItem xs="12" md="2"><MudButton Variant="Variant.Filled" OnClick="AddServerAsync">Server speichern</MudButton></MudItem>
</MudGrid>
</MudPaper>
<MudTable Items="servers" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh><MudTh>Host</MudTh><MudTh>Port</MudTh><MudTh>Username</MudTh><MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => TestServerAsync(context)">Verbindung testen</MudButton>
<MudButton Size="Size.Small" Color="Color.Error" Variant="Variant.Text" OnClick="() => DeleteServerAsync(context.Id)">Delete</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-4">@message</MudAlert>
@code {
private List<Site> sites = [];
private List<HanaServer> servers = [];
private Site newSite = new() { IsActive = true };
private Site? editingSite;
private HanaServer newServer = new() { Port = 30015 };
private string newServerPassword = string.Empty;
private string message = "Bereit.";
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
servers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
sites = await db.Sites.Include(x => x.HanaServer).OrderBy(x => x.Land).ToListAsync();
if (servers.Count > 0 && newSite.HanaServerId == 0)
{
newSite.HanaServerId = servers[0].Id;
}
}
private async Task AddSiteAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
db.Sites.Add(new Site
{
HanaServerId = newSite.HanaServerId,
Schema = newSite.Schema,
TSC = newSite.TSC,
Land = newSite.Land,
IsActive = newSite.IsActive
});
await db.SaveChangesAsync();
newSite = new Site { IsActive = true, HanaServerId = servers.FirstOrDefault()?.Id ?? 0 };
await LoadAsync();
}
private void EditSite(Site site)
{
editingSite = new Site
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
IsActive = site.IsActive
};
}
private async Task SaveSiteAsync()
{
if (editingSite is null)
{
return;
}
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Sites.SingleAsync(x => x.Id == editingSite.Id);
entity.HanaServerId = editingSite.HanaServerId;
entity.Schema = editingSite.Schema;
entity.TSC = editingSite.TSC;
entity.Land = editingSite.Land;
entity.IsActive = editingSite.IsActive;
await db.SaveChangesAsync();
editingSite = null;
await LoadAsync();
}
private async Task DeleteSiteAsync(int id)
{
await using var db = await DbFactory.CreateDbContextAsync();
var site = await db.Sites.SingleAsync(x => x.Id == id);
db.Sites.Remove(site);
await db.SaveChangesAsync();
await LoadAsync();
}
private async Task AddServerAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
db.HanaServers.Add(new HanaServer
{
Name = newServer.Name,
Host = newServer.Host,
Port = newServer.Port,
Username = newServer.Username,
EncryptedPassword = CryptoService.Encrypt(newServerPassword)
});
await db.SaveChangesAsync();
newServer = new HanaServer { Port = 30015 };
newServerPassword = string.Empty;
await LoadAsync();
}
private async Task DeleteServerAsync(int id)
{
await using var db = await DbFactory.CreateDbContextAsync();
var isUsed = await db.Sites.AnyAsync(x => x.HanaServerId == id);
if (isUsed)
{
message = "Server kann nicht gelöscht werden, solange Sites darauf zeigen.";
return;
}
var server = await db.HanaServers.SingleAsync(x => x.Id == id);
db.HanaServers.Remove(server);
await db.SaveChangesAsync();
await LoadAsync();
}
private async Task TestServerAsync(HanaServer server)
{
try
{
var ok = HanaQueryService.TestConnection(server.Host, server.Port, server.Username, CryptoService.Decrypt(server.EncryptedPassword));
message = ok ? $"Verbindung OK: {server.Name}" : $"Verbindung fehlgeschlagen: {server.Name}";
}
catch (Exception ex)
{
message = $"Verbindung fehlgeschlagen: {ex.Message}";
}
await InvokeAsync(StateHasChanged);
}
}