Add German localization and styling for administration module

- Introduced new JavaScript file `fis.admin_txt_de.js` for German translations of administration-related terms and messages.
- Created `fis.admin.css` for styling the administration interface, including layout, cards, and buttons.
- Added `fis.admin.de.js` for the main functionality of the administration module, implementing features such as system status checks and email testing.
- Minified version of the German JavaScript file created as `fis.admin.de.min.js`.
- Minified CSS file created as `fis.admin.min.css` for optimized loading.
This commit is contained in:
Stefan
2026-07-16 14:59:14 +02:00
parent f6079af0de
commit 8a0ebeeb1e
34 changed files with 2765 additions and 16 deletions
+81
View File
@@ -89,6 +89,87 @@ public class AzureBlobStorageService : IBlobStorageService
}
}
public async Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default)
{
if (!_settings.Enabled)
return new BlobConnectivity { Enabled = false, Configured = _client != null };
if (_client == null)
return new BlobConnectivity
{
Enabled = true,
Configured = false,
Detail = "ConnectionStrings:AzureBlobStorage_ConnectionString ist nicht konfiguriert.",
};
using var act = FuchsTelemetry.StartActivity("blobstorage.connectivity");
try
{
AccountInfo info = await _client.GetAccountInfoAsync(cancellationToken);
var containers = await CountContainersAsync(cancellationToken);
return new BlobConnectivity
{
Enabled = true,
Configured = true,
Reachable = true,
AccountName = _client.AccountName,
Detail = $"{info.AccountKind} / {info.SkuName}",
Containers = containers,
};
}
catch (Exception ex)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogWarning(ex, "Blob connectivity check failed for account {Account}.", _client.AccountName);
return new BlobConnectivity
{
Enabled = true,
Configured = true,
Reachable = false,
AccountName = _client.AccountName,
Detail = ex.Message,
};
}
}
/// <summary>
/// Counts the blobs in each distinct configured container (invoice + reminder). A container
/// that does not exist yet reports <see cref="BlobContainerCount.Exists"/> = false / count 0;
/// a container whose listing throws reports count -1. Never throws.
/// </summary>
private async Task<List<BlobContainerCount>> CountContainersAsync(CancellationToken cancellationToken)
{
var result = new List<BlobContainerCount>();
if (_client == null) return result;
var names = new[] { _settings.InvoiceContainer, _settings.ReminderContainer }
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var name in names)
{
try
{
var container = _client.GetBlobContainerClient(name);
bool exists = await container.ExistsAsync(cancellationToken);
int count = 0;
if (exists)
{
await foreach (var _ in container.GetBlobsAsync(cancellationToken: cancellationToken))
count++;
}
result.Add(new BlobContainerCount { Name = name, Exists = exists, FileCount = count });
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob file count failed for container {Container}.", name);
result.Add(new BlobContainerCount { Name = name, Exists = false, FileCount = -1 });
}
}
return result;
}
private static string BuildBlobName(string documentId, string fileName) =>
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
+45
View File
@@ -44,4 +44,49 @@ public interface IBlobStorageService
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies connectivity and access to the configured storage account by requesting its
/// account information. Read-only, never mutates, and never throws — any failure is reported
/// via <see cref="BlobConnectivity.Reachable"/>/<see cref="BlobConnectivity.Detail"/>. Used by
/// the Admin module's system-status view.
/// </summary>
Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default);
}
/// <summary>Result of <see cref="IBlobStorageService.CheckConnectivityAsync"/> — no secrets.</summary>
public sealed class BlobConnectivity
{
/// <summary><c>Fuchs:AzureStorage:Enabled</c>.</summary>
public bool Enabled { get; init; }
/// <summary>A storage client could be constructed (connection string present + valid form).</summary>
public bool Configured { get; init; }
/// <summary>The account responded to an authenticated request.</summary>
public bool Reachable { get; init; }
/// <summary>Storage account name (never the key/connection string).</summary>
public string? AccountName { get; init; }
/// <summary>Non-sensitive detail: account kind/SKU on success, error summary on failure.</summary>
public string? Detail { get; init; }
/// <summary>
/// Per-configured-container blob (file) counts, populated when the account was reachable.
/// <c>null</c> when connectivity failed.
/// </summary>
public IReadOnlyList<BlobContainerCount>? Containers { get; init; }
}
/// <summary>Blob (file) count for one configured container.</summary>
public sealed class BlobContainerCount
{
public string Name { get; init; } = "";
/// <summary><c>false</c> when the container does not exist yet (created lazily on first upload).</summary>
public bool Exists { get; init; }
/// <summary>Number of blobs in the container; <c>-1</c> when the count could not be determined.</summary>
public int FileCount { get; init; }
}
+28
View File
@@ -0,0 +1,28 @@
namespace Fuchs.Services;
/// <summary>
/// Gathers system configuration/environment facts and runs live connectivity/access probes
/// for the Admin module. Read-only diagnostics: probes never mutate state and never throw
/// (failures are captured in the returned <see cref="SystemProbeResult"/>).
/// </summary>
public interface ISystemStatusService
{
/// <summary>Passive snapshot of hosting, database, email, blob, MFR and Key Vault configuration.</summary>
SystemInfoSnapshot GetInfo();
/// <summary>The set of probe component ids this service knows how to run.</summary>
IReadOnlyList<string> ProbeComponents { get; }
/// <summary>Runs a single probe by component id (<c>database</c>/<c>keyvault</c>/<c>blob</c>/<c>mfr</c>).</summary>
Task<SystemProbeResult> ProbeAsync(string component, CancellationToken cancellationToken = default);
/// <summary>Runs every known probe (in parallel) and returns the results.</summary>
Task<IReadOnlyList<SystemProbeResult>> ProbeAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
/// exactly as for any other outbound email.
/// </summary>
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
}
+46
View File
@@ -0,0 +1,46 @@
namespace Fuchs.Services;
/// <summary>
/// Process-lifetime holder for the result of the one-shot <see cref="StartupSelfTestService"/> run.
/// Registered as a singleton: the startup service writes the report once at boot, and the Admin
/// module reads it back (via <see cref="ISystemStatusService.GetInfo"/>) as a non-refreshable
/// widget — the checks are inherently a startup event and are never re-run per request.
/// </summary>
public sealed class StartupCheckReporter
{
private volatile StartupCheckReport? _report;
/// <summary>The latest (and only) startup report, or <c>null</c> if the service has not run yet.</summary>
public StartupCheckReport? Latest => _report;
public void Set(StartupCheckReport report) => _report = report;
}
/// <summary>Snapshot of a completed startup self-test run. Serialized to the Admin frontend.</summary>
public sealed class StartupCheckReport
{
/// <summary><c>false</c> when <c>Fuchs:StartupChecks:Enabled</c> was off, so nothing was probed.</summary>
public bool Ran { get; init; }
/// <summary>When the run finished (UTC); <c>null</c> when it did not run.</summary>
public DateTimeOffset? CompletedUtc { get; init; }
/// <summary>The machine the startup run executed on.</summary>
public string MachineName { get; init; } = "";
/// <summary>Per-check outcomes (only meaningful for enabled checks).</summary>
public IReadOnlyList<StartupCheckItem> Items { get; init; } = [];
}
/// <summary>One startup check's configured/enabled state and its boot-time result.</summary>
public sealed class StartupCheckItem
{
/// <summary>Stable key: <c>KeyVault</c> | <c>Database</c> | <c>MFR</c> | <c>PdfLicense</c> | <c>Mailer</c>.</summary>
public string Name { get; init; } = "";
/// <summary>Whether this check was enabled (and therefore actually ran).</summary>
public bool Enabled { get; init; }
/// <summary>Result of the check; only meaningful when <see cref="Enabled"/> is true.</summary>
public bool Ok { get; init; }
}
+20 -1
View File
@@ -20,17 +20,20 @@ public class StartupSelfTestService : BackgroundService
private readonly IConfiguration _configuration;
private readonly StartupSelfTestSettings _settings;
private readonly ILogger<StartupSelfTestService> _logger;
private readonly StartupCheckReporter? _reporter;
public StartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
ILogger<StartupSelfTestService> logger)
ILogger<StartupSelfTestService> logger,
StartupCheckReporter? reporter = null)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_settings = settings.Value;
_logger = logger;
_reporter = reporter;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -41,6 +44,7 @@ public class StartupSelfTestService : BackgroundService
if (!_settings.Enabled)
{
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
_reporter?.Set(new StartupCheckReport { Ran = false, MachineName = Environment.MachineName });
return;
}
@@ -124,6 +128,21 @@ public class StartupSelfTestService : BackgroundService
mfrOk,
mailerOk,
pdfLicenseOk);
_reporter?.Set(new StartupCheckReport
{
Ran = true,
CompletedUtc = DateTimeOffset.UtcNow,
MachineName = Environment.MachineName,
Items =
[
new StartupCheckItem { Name = "KeyVault", Enabled = _settings.CheckKeyVault, Ok = keyVaultOk },
new StartupCheckItem { Name = "Database", Enabled = _settings.CheckDatabase, Ok = databaseOk },
new StartupCheckItem { Name = "MFR", Enabled = _settings.CheckMfr, Ok = mfrOk },
new StartupCheckItem { Name = "PdfLicense", Enabled = _settings.CheckPdfLicense, Ok = pdfLicenseOk },
new StartupCheckItem { Name = "Mailer", Enabled = _settings.SendStartupEmail, Ok = mailerOk },
],
});
}
catch (OperationCanceledException)
{
+132
View File
@@ -0,0 +1,132 @@
namespace Fuchs.Services;
/// <summary>
/// Result of a single connectivity/access probe run by <see cref="ISystemStatusService"/>.
/// Serialized directly to the Admin module frontend, so property names are the JSON contract.
/// </summary>
public sealed class SystemProbeResult
{
/// <summary>Probe identifier: <c>database</c>, <c>keyvault</c>, <c>blob</c> or <c>mfr</c>.</summary>
public string Component { get; init; } = "";
/// <summary><c>ok</c> | <c>error</c> | <c>disabled</c> | <c>unconfigured</c>.</summary>
public string Status { get; init; } = "unconfigured";
/// <summary><c>true</c> only when the component was reachable and access succeeded.</summary>
public bool Ok { get; init; }
/// <summary>Short human-readable summary (German), safe to show in the UI.</summary>
public string Message { get; init; } = "";
/// <summary>Optional extra detail (server name, account name, error text — never secrets).</summary>
public string? Detail { get; init; }
/// <summary>Round-trip duration of the probe in milliseconds.</summary>
public long DurationMs { get; init; }
/// <summary>When the probe ran (UTC).</summary>
public DateTimeOffset CheckedUtc { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Optional extra label/value pairs a probe wants shown alongside its result — e.g. the blob
/// probe reports the file count per container here. <c>null</c> for probes that have none.
/// </summary>
public IReadOnlyList<ProbeMetric>? Metrics { get; init; }
public static SystemProbeResult Disabled(string component, string message) =>
new() { Component = component, Status = "disabled", Ok = false, Message = message };
public static SystemProbeResult Unconfigured(string component, string message) =>
new() { Component = component, Status = "unconfigured", Ok = false, Message = message };
}
/// <summary>A named measurement a probe surfaces for display (e.g. "dev-fuchs-invoices" → "42 Dateien").</summary>
public sealed class ProbeMetric
{
public string Label { get; init; } = "";
public string Value { get; init; } = "";
}
/// <summary>
/// Passive configuration/environment snapshot (no network calls). Complements the
/// active <see cref="SystemProbeResult"/> probes with "how is this configured" facts.
/// Secrets are never included — only presence flags and non-sensitive settings.
/// </summary>
public sealed class SystemInfoSnapshot
{
public string MachineName { get; init; } = "";
public string OsDescription { get; init; } = "";
public string FrameworkDescription { get; init; } = "";
public string Environment { get; init; } = "";
public bool IsTestDeployment { get; init; }
public int ProcessId { get; init; }
public DateTimeOffset ProcessStartUtc { get; init; }
public double UptimeHours { get; init; }
public DatabaseInfo Database { get; init; } = new();
public EmailInfo Email { get; init; } = new();
public BlobInfo Blob { get; init; } = new();
public MfrInfo Mfr { get; init; } = new();
public KeyVaultInfo KeyVault { get; init; } = new();
/// <summary>Result of the one-shot startup self-test, or <c>null</c> if it has not run.</summary>
public StartupCheckReport? StartupChecks { get; init; }
public sealed class DatabaseInfo
{
public bool Configured { get; init; }
public string? Server { get; init; }
public string? Catalog { get; init; }
public string? UserId { get; init; }
}
public sealed class EmailInfo
{
public bool MailerEnabled { get; init; }
public string? BaseUrl { get; init; }
public string? AccountId { get; init; }
public string? ServerId { get; init; }
public bool TokenConfigured { get; init; }
public bool OverrideActive { get; init; }
public string? OverrideRecipient { get; init; }
}
public sealed class BlobInfo
{
public bool Enabled { get; init; }
public bool Configured { get; init; }
public string? InvoiceContainer { get; init; }
public string? ReminderContainer { get; init; }
}
public sealed class MfrInfo
{
public string? Host { get; init; }
public bool CredentialsConfigured { get; init; }
public bool SyncEnabled { get; init; }
}
public sealed class KeyVaultInfo
{
public string? VaultUri { get; init; }
public string? AppName { get; init; }
public int ManagedSecretCount { get; init; }
public bool ClientRegistered { get; init; }
}
}
/// <summary>Outcome of an Admin test-email send.</summary>
public sealed class EmailTestResult
{
public bool Sent { get; init; }
public string Message { get; init; } = "";
/// <summary>Address the caller requested.</summary>
public string RequestedRecipient { get; init; } = "";
/// <summary>
/// When the dev/test override is active, the address the mail was actually redirected to
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
/// </summary>
public string? OverrideRecipient { get; init; }
}
+415
View File
@@ -0,0 +1,415 @@
using System.Diagnostics;
using Azure;
using Azure.Security.KeyVault.Secrets;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// Read-only diagnostics for the Admin module: a passive configuration snapshot plus live
/// connectivity/access probes (database, Key Vault, blob storage, MFR ERP) and a test-email
/// sender. Every probe is wrapped so it reports failure via <see cref="SystemProbeResult"/>
/// instead of throwing. No secrets are ever returned — only presence flags and non-sensitive
/// values (server/account names, error messages).
/// </summary>
public sealed class SystemStatusService : ISystemStatusService
{
private static readonly string[] Components = { "database", "keyvault", "blob", "mfr" };
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
private readonly IServiceProvider _serviceProvider;
private readonly IBlobStorageService _blob;
private readonly IMfrClientFactory _mfrFactory;
private readonly IComService _comService;
private readonly ProcessWebComSettings _mailer;
private readonly FuchsEmailSettings _email;
private readonly AzureBlobStorageSettings _blobSettings;
private readonly StartupCheckReporter _startupChecks;
private readonly ILogger<SystemStatusService> _logger;
public SystemStatusService(
IConfiguration configuration,
IHostEnvironment environment,
IServiceProvider serviceProvider,
IBlobStorageService blob,
IMfrClientFactory mfrFactory,
IComService comService,
IOptions<ProcessWebComSettings> mailer,
IOptions<FuchsEmailSettings> email,
IOptions<AzureBlobStorageSettings> blobSettings,
StartupCheckReporter startupChecks,
ILogger<SystemStatusService> logger)
{
_configuration = configuration;
_environment = environment;
_serviceProvider = serviceProvider;
_blob = blob;
_mfrFactory = mfrFactory;
_comService = comService;
_mailer = mailer.Value;
_email = email.Value;
_blobSettings = blobSettings.Value;
_startupChecks = startupChecks;
_logger = logger;
}
public IReadOnlyList<string> ProbeComponents => Components;
public SystemInfoSnapshot GetInfo()
{
using var process = Process.GetCurrentProcess();
DateTimeOffset startUtc;
try { startUtc = process.StartTime.ToUniversalTime(); }
catch { startUtc = DateTimeOffset.UtcNow; }
return new SystemInfoSnapshot
{
MachineName = Environment.MachineName,
OsDescription = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
FrameworkDescription = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
Environment = _environment.EnvironmentName,
IsTestDeployment = _configuration.GetValue("Fuchs:IsTestDeployment", false),
ProcessId = process.Id,
ProcessStartUtc = startUtc,
UptimeHours = Math.Round((DateTimeOffset.UtcNow - startUtc).TotalHours, 2),
Database = BuildDatabaseInfo(),
Email = BuildEmailInfo(),
Blob = BuildBlobInfo(),
Mfr = BuildMfrInfo(),
KeyVault = BuildKeyVaultInfo(),
StartupChecks = _startupChecks.Latest,
};
}
private SystemInfoSnapshot.DatabaseInfo BuildDatabaseInfo()
{
string? cs = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (string.IsNullOrWhiteSpace(cs))
return new SystemInfoSnapshot.DatabaseInfo { Configured = false };
try
{
var b = new SqlConnectionStringBuilder(cs);
return new SystemInfoSnapshot.DatabaseInfo
{
Configured = true,
Server = b.DataSource,
Catalog = b.InitialCatalog,
UserId = b.UserID, // login name only; the password is never surfaced
};
}
catch
{
return new SystemInfoSnapshot.DatabaseInfo { Configured = true };
}
}
private SystemInfoSnapshot.EmailInfo BuildEmailInfo() => new()
{
MailerEnabled = _mailer.Enabled,
BaseUrl = _mailer.BaseUrl,
AccountId = _mailer.AccountId,
ServerId = _mailer.ServerId,
TokenConfigured = !string.IsNullOrWhiteSpace(_mailer.Token) && _mailer.Token != "MANAGED_BY_KEYVAULT",
OverrideActive = !string.IsNullOrWhiteSpace(_email.OverrideRecipient),
OverrideRecipient = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient,
};
private SystemInfoSnapshot.BlobInfo BuildBlobInfo()
{
string? cs = _configuration.GetConnectionString("AzureBlobStorage_ConnectionString");
return new SystemInfoSnapshot.BlobInfo
{
Enabled = _blobSettings.Enabled,
Configured = !string.IsNullOrWhiteSpace(cs) && cs != "MANAGED_BY_KEYVAULT",
InvoiceContainer = _blobSettings.InvoiceContainer,
ReminderContainer = _blobSettings.ReminderContainer,
};
}
private SystemInfoSnapshot.MfrInfo BuildMfrInfo()
{
string? host = _configuration["Fds:MFR_host"];
string? user = _configuration["Fds:MFR_UserName"];
return new SystemInfoSnapshot.MfrInfo
{
Host = host,
CredentialsConfigured = !string.IsNullOrWhiteSpace(user) && user != "MANAGED_BY_KEYVAULT",
SyncEnabled = _configuration.GetValue("Fds:SyncEnabled", false),
};
}
private SystemInfoSnapshot.KeyVaultInfo BuildKeyVaultInfo()
{
string[] keys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
return new SystemInfoSnapshot.KeyVaultInfo
{
VaultUri = _configuration["SecretManagement:VaultUri"],
AppName = _configuration["SecretManagement:AppName"],
ManagedSecretCount = keys.Length,
ClientRegistered = _serviceProvider.GetService<SecretClient>() != null,
};
}
public async Task<IReadOnlyList<SystemProbeResult>> ProbeAllAsync(CancellationToken cancellationToken = default)
{
var tasks = Components.Select(c => ProbeAsync(c, cancellationToken));
return await Task.WhenAll(tasks);
}
public async Task<SystemProbeResult> ProbeAsync(string component, CancellationToken cancellationToken = default)
{
component = (component ?? "").Trim().ToLowerInvariant();
using var act = FuchsTelemetry.StartActivity("systemstatus.probe");
act?.SetTag("fuchs.systemstatus.component", component);
var sw = Stopwatch.StartNew();
SystemProbeResult result;
try
{
result = component switch
{
"database" => await ProbeDatabaseAsync(cancellationToken),
"keyvault" => await ProbeKeyVaultAsync(cancellationToken),
"blob" => await ProbeBlobAsync(cancellationToken),
"mfr" => await ProbeMfrAsync(cancellationToken),
_ => new SystemProbeResult
{
Component = component,
Status = "error",
Ok = false,
Message = $"Unbekannte Komponente '{component}'.",
},
};
}
catch (Exception ex)
{
// Defense in depth: individual probes already catch, but never let a probe throw.
_logger.LogWarning(ex, "System status probe {Component} threw unexpectedly.", component);
result = new SystemProbeResult
{
Component = component,
Status = "error",
Ok = false,
Message = "Unerwarteter Fehler bei der Prüfung.",
Detail = ex.Message,
};
}
sw.Stop();
result = new SystemProbeResult
{
Component = result.Component,
Status = result.Status,
Ok = result.Ok,
Message = result.Message,
Detail = result.Detail,
Metrics = result.Metrics,
DurationMs = sw.ElapsedMilliseconds,
CheckedUtc = DateTimeOffset.UtcNow,
};
act?.SetTag("fuchs.systemstatus.status", result.Status);
FuchsTelemetry.SystemProbes.Add(1,
new KeyValuePair<string, object?>("component", component),
new KeyValuePair<string, object?>("status", result.Status));
_logger.LogInformation("System status probe {Component} => {Status} in {Ms} ms.",
component, result.Status, sw.ElapsedMilliseconds);
return result;
}
private async Task<SystemProbeResult> ProbeDatabaseAsync(CancellationToken ct)
{
string? cs = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (string.IsNullOrWhiteSpace(cs))
return SystemProbeResult.Unconfigured("database", "Keine Verbindungszeichenfolge konfiguriert.");
try
{
await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(
"SELECT CONVERT(nvarchar(256), @@SERVERNAME), DB_NAME(), SUSER_SNAME();", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct);
string server = "", db = "", login = "";
if (await reader.ReadAsync(ct))
{
server = reader.IsDBNull(0) ? "" : reader.GetString(0);
db = reader.IsDBNull(1) ? "" : reader.GetString(1);
login = reader.IsDBNull(2) ? "" : reader.GetString(2);
}
return new SystemProbeResult
{
Component = "database",
Status = "ok",
Ok = true,
Message = "Verbindung und Anmeldung erfolgreich.",
Detail = $"{server} / {db} (Login: {login})",
};
}
catch (Exception ex)
{
return new SystemProbeResult
{
Component = "database",
Status = "error",
Ok = false,
Message = "Verbindung oder Zugriff fehlgeschlagen.",
Detail = ex.Message,
};
}
}
private async Task<SystemProbeResult> ProbeKeyVaultAsync(CancellationToken ct)
{
string appName = _configuration["SecretManagement:AppName"] ?? "";
string[] managedKeys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(appName) || managedKeys.Length == 0)
return SystemProbeResult.Unconfigured("keyvault", "SecretManagement-Einstellungen unvollständig.");
var secretClient = _serviceProvider.GetService<SecretClient>();
if (secretClient is null)
return SystemProbeResult.Unconfigured("keyvault", "SecretClient ist nicht registriert.");
string probeName = $"{appName}--{managedKeys[0]}";
try
{
KeyVaultSecret secret = await secretClient.GetSecretAsync(probeName, version: null, ct);
bool hasValue = !string.IsNullOrWhiteSpace(secret.Value);
return new SystemProbeResult
{
Component = "keyvault",
Status = hasValue ? "ok" : "error",
Ok = hasValue,
Message = hasValue ? "Zugriff auf Key Vault erfolgreich." : "Secret ist leer.",
Detail = $"Geprüft: {probeName}",
};
}
catch (RequestFailedException ex)
{
return new SystemProbeResult
{
Component = "keyvault",
Status = "error",
Ok = false,
Message = "Zugriff auf Key Vault fehlgeschlagen.",
Detail = $"Status {ex.Status}: {ex.Message}",
};
}
catch (Exception ex)
{
return new SystemProbeResult
{
Component = "keyvault",
Status = "error",
Ok = false,
Message = "Zugriff auf Key Vault fehlgeschlagen.",
Detail = ex.Message,
};
}
}
private async Task<SystemProbeResult> ProbeBlobAsync(CancellationToken ct)
{
BlobConnectivity c = await _blob.CheckConnectivityAsync(ct);
if (!c.Enabled)
return SystemProbeResult.Disabled("blob", "Azure Blob Storage ist deaktiviert (Fuchs:AzureStorage:Enabled=false).");
if (!c.Configured)
return SystemProbeResult.Unconfigured("blob", c.Detail ?? "Keine Verbindungszeichenfolge konfiguriert.");
List<ProbeMetric>? metrics = c.Containers?
.Select(x => new ProbeMetric
{
Label = x.Name,
Value = !x.Exists ? "nicht vorhanden"
: x.FileCount < 0 ? "Anzahl nicht ermittelbar"
: $"{x.FileCount} Datei{(x.FileCount == 1 ? "" : "en")}",
})
.ToList();
string message = c.Reachable ? "Verbindung und Zugriff erfolgreich." : "Zugriff fehlgeschlagen.";
if (c.Reachable && c.Containers is { Count: > 0 })
{
int total = c.Containers.Where(x => x.FileCount > 0).Sum(x => x.FileCount);
message += $" {total} Datei{(total == 1 ? "" : "en")} gesamt.";
}
return new SystemProbeResult
{
Component = "blob",
Status = c.Reachable ? "ok" : "error",
Ok = c.Reachable,
Message = message,
Detail = string.IsNullOrWhiteSpace(c.AccountName) ? c.Detail : $"{c.AccountName}: {c.Detail}",
Metrics = metrics,
};
}
private async Task<SystemProbeResult> ProbeMfrAsync(CancellationToken ct)
{
try
{
using var client = _mfrFactory.Create();
string entities = await client.GetEntities(throwErrorIfNotOk: true);
bool ok = !string.IsNullOrWhiteSpace(entities);
return new SystemProbeResult
{
Component = "mfr",
Status = ok ? "ok" : "error",
Ok = ok,
Message = ok ? "Verbindung und Zugriff erfolgreich." : "Leere Antwort erhalten.",
Detail = _configuration["Fds:MFR_host"],
};
}
catch (Exception ex)
{
return new SystemProbeResult
{
Component = "mfr",
Status = "error",
Ok = false,
Message = "Verbindung oder Zugriff fehlgeschlagen.",
Detail = ex.Message,
};
}
}
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
CancellationToken cancellationToken = default)
{
to = (to ?? "").Trim();
subject = (subject ?? "").Trim();
body = body ?? "";
if (to.Length == 0 || subject.Length == 0)
return new EmailTestResult { Sent = false, Message = "Empfänger und Betreff sind erforderlich.", RequestedRecipient = to };
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null);
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
string message = sent
? (overrideTo is null
? "Test-E-Mail wurde an den Mailer übergeben."
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).")
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert).";
return new EmailTestResult
{
Sent = sent,
Message = message,
RequestedRecipient = to,
OverrideRecipient = overrideTo,
};
}
}