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:
@@ -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)}";
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user