Add backend-authoritative invoice draft editing (ADR 0006/0007) #1
@@ -47,8 +47,8 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
|
||||
@@ -205,6 +205,54 @@ public class StartupSelfTestServiceTests
|
||||
Assert.Equal(1, service.MailerCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_Disabled_PublishesNotRunReport()
|
||||
{
|
||||
var reporter = new StartupCheckReporter();
|
||||
using var service = new TestableStartupSelfTestService(
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
CreateConfiguration(),
|
||||
Options.Create(CreateSettings(enabled: false)),
|
||||
NullLogger<StartupSelfTestService>.Instance,
|
||||
reporter);
|
||||
|
||||
await service.RunForTestAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(reporter.Latest);
|
||||
Assert.False(reporter.Latest!.Ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults()
|
||||
{
|
||||
var reporter = new StartupCheckReporter();
|
||||
using var service = new TestableStartupSelfTestService(
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
CreateConfiguration(),
|
||||
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)),
|
||||
NullLogger<StartupSelfTestService>.Instance,
|
||||
reporter)
|
||||
{
|
||||
KeyVaultResult = true,
|
||||
DatabaseResult = false,
|
||||
MailerResult = true,
|
||||
};
|
||||
|
||||
await service.RunForTestAsync(CancellationToken.None);
|
||||
|
||||
var report = reporter.Latest;
|
||||
Assert.NotNull(report);
|
||||
Assert.True(report!.Ran);
|
||||
Assert.NotNull(report.CompletedUtc);
|
||||
var kv = Assert.Single(report.Items, i => i.Name == "KeyVault");
|
||||
Assert.True(kv.Enabled && kv.Ok);
|
||||
var db = Assert.Single(report.Items, i => i.Name == "Database");
|
||||
Assert.True(db.Enabled);
|
||||
Assert.False(db.Ok); // intentionally-failing path
|
||||
var mfr = Assert.Single(report.Items, i => i.Name == "MFR");
|
||||
Assert.False(mfr.Enabled); // disabled check reported as not-enabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
|
||||
{
|
||||
@@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
IOptions<StartupSelfTestSettings> settings,
|
||||
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
|
||||
: base(serviceProvider, configuration, settings, logger)
|
||||
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger,
|
||||
StartupCheckReporter? reporter = null)
|
||||
: base(serviceProvider, configuration, settings, logger, reporter)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Admin module's read-only diagnostics service. Covers the passive config
|
||||
/// snapshot (SQL server parsing, email override reflection, presence flags), each probe's
|
||||
/// disabled/unconfigured/ok/error outcomes where deterministically reachable without live
|
||||
/// infrastructure, the test-email pipeline (success/failure/validation + HTML-encoding +
|
||||
/// override reporting), and the emitted probe telemetry.
|
||||
/// </summary>
|
||||
public class SystemStatusServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
|
||||
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
|
||||
{
|
||||
var dict = new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] =
|
||||
"Data Source=SQLHOST,1433;Initial Catalog=site_fuchs_test;User ID=fuchs_app;password='secret';",
|
||||
["ConnectionStrings:AzureBlobStorage_ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=key==;",
|
||||
["Fuchs:IsTestDeployment"] = "true",
|
||||
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
|
||||
["Fds:MFR_UserName"] = "mfruser",
|
||||
["Fds:SyncEnabled"] = "true",
|
||||
["SecretManagement:VaultUri"] = "https://vault.example/",
|
||||
["SecretManagement:AppName"] = "fuchs",
|
||||
["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token",
|
||||
["SecretManagement:ManagedSecretKeys:1"] = "ConnectionStrings--fuchs-fds-password",
|
||||
};
|
||||
if (overrides != null)
|
||||
foreach (var kv in overrides) dict[kv.Key] = kv.Value;
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
|
||||
}
|
||||
|
||||
private static SystemStatusService Build(
|
||||
IConfiguration? config = null,
|
||||
Mock<IBlobStorageService>? blob = null,
|
||||
Mock<IMfrClientFactory>? mfr = null,
|
||||
Mock<IComService>? com = null,
|
||||
ProcessWebComSettings? mailer = null,
|
||||
FuchsEmailSettings? email = null,
|
||||
AzureBlobStorageSettings? blobSettings = null,
|
||||
IServiceProvider? provider = null,
|
||||
StartupCheckReporter? startupChecks = null)
|
||||
{
|
||||
var env = new Mock<IHostEnvironment>();
|
||||
env.SetupGet(e => e.EnvironmentName).Returns("Development");
|
||||
|
||||
return new SystemStatusService(
|
||||
config ?? Config(),
|
||||
env.Object,
|
||||
provider ?? new ServiceCollection().BuildServiceProvider(),
|
||||
(blob ?? new Mock<IBlobStorageService>()).Object,
|
||||
(mfr ?? new Mock<IMfrClientFactory>()).Object,
|
||||
(com ?? new Mock<IComService>()).Object,
|
||||
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
|
||||
Options.Create(email ?? new FuchsEmailSettings()),
|
||||
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
|
||||
startupChecks ?? new StartupCheckReporter(),
|
||||
NullLogger<SystemStatusService>.Instance);
|
||||
}
|
||||
|
||||
// ── GetInfo (passive snapshot) ─────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void GetInfo_ParsesConnectionStringAndReflectsConfiguration()
|
||||
{
|
||||
var svc = Build();
|
||||
|
||||
SystemInfoSnapshot info = svc.GetInfo();
|
||||
|
||||
Assert.Equal("Development", info.Environment);
|
||||
Assert.True(info.IsTestDeployment);
|
||||
Assert.True(info.Database.Configured);
|
||||
Assert.Equal("SQLHOST,1433", info.Database.Server);
|
||||
Assert.Equal("site_fuchs_test", info.Database.Catalog);
|
||||
Assert.Equal("fuchs_app", info.Database.UserId); // login only, never the password
|
||||
Assert.True(info.Mfr.CredentialsConfigured);
|
||||
Assert.Equal("portal.mobilefieldreport.com", info.Mfr.Host);
|
||||
Assert.True(info.Mfr.SyncEnabled);
|
||||
Assert.Equal("fuchs", info.KeyVault.AppName);
|
||||
Assert.Equal(2, info.KeyVault.ManagedSecretCount);
|
||||
Assert.False(info.KeyVault.ClientRegistered); // empty provider
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_NeverLeaksDatabasePassword()
|
||||
{
|
||||
var svc = Build();
|
||||
|
||||
SystemInfoSnapshot info = svc.GetInfo();
|
||||
|
||||
Assert.DoesNotContain("secret", info.Database.UserId ?? "");
|
||||
Assert.DoesNotContain("secret", info.Database.Server ?? "");
|
||||
Assert.DoesNotContain("secret", info.Database.Catalog ?? "");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void GetInfo_EmailOverride_ReflectedOnlyWhenConfigured(bool overrideSet)
|
||||
{
|
||||
var email = new FuchsEmailSettings { OverrideRecipient = overrideSet ? "safety@example.test" : "" };
|
||||
var svc = Build(email: email);
|
||||
|
||||
SystemInfoSnapshot info = svc.GetInfo();
|
||||
|
||||
Assert.Equal(overrideSet, info.Email.OverrideActive);
|
||||
Assert.Equal(overrideSet ? "safety@example.test" : null, info.Email.OverrideRecipient);
|
||||
Assert.True(info.Email.MailerEnabled); // Build() default enables the mailer
|
||||
Assert.True(info.Email.TokenConfigured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_StartupChecks_NullWhenServiceHasNotRun()
|
||||
{
|
||||
var svc = Build(startupChecks: new StartupCheckReporter()); // nothing set yet
|
||||
|
||||
Assert.Null(svc.GetInfo().StartupChecks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_StartupChecks_ReflectsReporterContents()
|
||||
{
|
||||
var reporter = new StartupCheckReporter();
|
||||
reporter.Set(new StartupCheckReport
|
||||
{
|
||||
Ran = true,
|
||||
CompletedUtc = DateTimeOffset.UtcNow,
|
||||
MachineName = "BUILD-HOST",
|
||||
Items =
|
||||
[
|
||||
new StartupCheckItem { Name = "Database", Enabled = true, Ok = true },
|
||||
new StartupCheckItem { Name = "Mailer", Enabled = false, Ok = false },
|
||||
],
|
||||
});
|
||||
var svc = Build(startupChecks: reporter);
|
||||
|
||||
var report = svc.GetInfo().StartupChecks;
|
||||
|
||||
Assert.NotNull(report);
|
||||
Assert.True(report!.Ran);
|
||||
Assert.Equal("BUILD-HOST", report.MachineName);
|
||||
Assert.Equal(2, report.Items.Count);
|
||||
Assert.Contains(report.Items, i => i.Name == "Database" && i.Enabled && i.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_DatabaseUnconfigured_WhenConnectionStringMissing()
|
||||
{
|
||||
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||
var svc = Build(config);
|
||||
|
||||
Assert.False(svc.GetInfo().Database.Configured);
|
||||
}
|
||||
|
||||
// ── Blob probe ─────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeBlob_Disabled_ReturnsDisabled()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("blob", r.Component);
|
||||
Assert.Equal("disabled", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_EnabledButUnconfigured_ReturnsUnconfigured()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = false, Detail = "keine Verbindungszeichenfolge" });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_Reachable_ReturnsOkWithAccountDetail()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = true, AccountName = "acct", Detail = "StorageV2 / Standard_LRS" });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("ok", r.Status);
|
||||
Assert.True(r.Ok);
|
||||
Assert.Contains("acct", r.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_Reachable_ReportsFileCountsPerContainerAsMetrics()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity
|
||||
{
|
||||
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
|
||||
Detail = "StorageV2 / Standard_LRS",
|
||||
Containers = new List<BlobContainerCount>
|
||||
{
|
||||
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = 42 },
|
||||
new() { Name = "dev-fuchs-reminders", Exists = false, FileCount = 0 },
|
||||
},
|
||||
});
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("ok", r.Status);
|
||||
Assert.NotNull(r.Metrics);
|
||||
Assert.Equal(2, r.Metrics!.Count);
|
||||
var inv = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-invoices");
|
||||
Assert.Equal("42 Dateien", inv.Value);
|
||||
var rem = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-reminders");
|
||||
Assert.Equal("nicht vorhanden", rem.Value);
|
||||
Assert.Contains("42 Dateien gesamt", r.Message); // total across containers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_FileCountUnavailable_ReportsMetricWithoutFailing()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity
|
||||
{
|
||||
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
|
||||
Containers = new List<BlobContainerCount>
|
||||
{
|
||||
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = -1 },
|
||||
},
|
||||
});
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("ok", r.Status);
|
||||
var inv = Assert.Single(r.Metrics!);
|
||||
Assert.Equal("Anzahl nicht ermittelbar", inv.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_ConfiguredButUnreachable_ReturnsError()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = false, AccountName = "acct", Detail = "403 Forbidden" });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
// ── Key Vault probe ────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
|
||||
{
|
||||
var svc = Build(); // empty service provider → no SecretClient
|
||||
|
||||
var r = await svc.ProbeAsync("keyvault");
|
||||
|
||||
Assert.Equal("keyvault", r.Component);
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeKeyVault_NoManagedKeys_ReturnsUnconfigured()
|
||||
{
|
||||
var config = Config(new()
|
||||
{
|
||||
["SecretManagement:ManagedSecretKeys:0"] = null,
|
||||
["SecretManagement:ManagedSecretKeys:1"] = null,
|
||||
});
|
||||
var svc = Build(config);
|
||||
|
||||
var r = await svc.ProbeAsync("keyvault");
|
||||
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
}
|
||||
|
||||
// ── MFR probe ──────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeMfr_FactoryThrows_ReturnsError()
|
||||
{
|
||||
var mfr = new Mock<IMfrClientFactory>();
|
||||
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("no credentials"));
|
||||
var svc = Build(mfr: mfr);
|
||||
|
||||
var r = await svc.ProbeAsync("mfr");
|
||||
|
||||
Assert.Equal("mfr", r.Component);
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
Assert.Equal("no credentials", r.Detail);
|
||||
}
|
||||
|
||||
// ── Database probe (unconfigured is deterministic without a live server) ────
|
||||
[Fact]
|
||||
public async Task ProbeDatabase_NoConnectionString_ReturnsUnconfigured()
|
||||
{
|
||||
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||
var svc = Build(config);
|
||||
|
||||
var r = await svc.ProbeAsync("database");
|
||||
|
||||
Assert.Equal("database", r.Component);
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
// ── Unknown component ──────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeAsync_UnknownComponent_ReturnsError()
|
||||
{
|
||||
var svc = Build();
|
||||
|
||||
var r = await svc.ProbeAsync("does-not-exist");
|
||||
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAllAsync_ReturnsOneResultPerComponent()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||
var mfr = new Mock<IMfrClientFactory>();
|
||||
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("x"));
|
||||
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||
var svc = Build(config, blob: blob, mfr: mfr);
|
||||
|
||||
var results = await svc.ProbeAllAsync();
|
||||
|
||||
Assert.Equal(svc.ProbeComponents.Count, results.Count);
|
||||
foreach (var comp in svc.ProbeComponents)
|
||||
Assert.Contains(results, r => r.Component == comp);
|
||||
Assert.All(results, r => Assert.True(r.DurationMs >= 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_EmitsProbeTelemetry()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.systemstatus.probes")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.systemstatus.probes counter should increment per probe.");
|
||||
}
|
||||
|
||||
// ── Test email ─────────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("", "subj", "body")]
|
||||
[InlineData("to@example.test", "", "body")]
|
||||
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotSend(string to, string subject, string body)
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync(to, subject, body);
|
||||
|
||||
Assert.False(r.Sent);
|
||||
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_Success_SendsHtmlEncodedBodyToRecipient()
|
||||
{
|
||||
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
|
||||
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
|
||||
.ReturnsAsync(true);
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1<script>\nZeile2");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal("dest@example.test", capturedTo);
|
||||
Assert.Equal("Betreff", capturedSubject);
|
||||
Assert.NotNull(capturedHtml);
|
||||
Assert.Contains("<script>", capturedHtml); // HTML-encoded, not injected
|
||||
Assert.DoesNotContain("<script>", capturedHtml);
|
||||
Assert.Contains("<br/>", capturedHtml); // newline → <br/>
|
||||
Assert.Null(r.OverrideRecipient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||
.ReturnsAsync(true);
|
||||
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
Assert.Equal("safety@example.test", r.OverrideRecipient);
|
||||
Assert.Contains("safety@example.test", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MailerRejects_ReturnsNotSent()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||
.ReturnsAsync(false);
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: Admin / system-status module.
|
||||
//
|
||||
// Access is restricted to users whose "fds_sys" module authorization is greater than 4.
|
||||
// The menu button is only shown, and the module script only loaded, for such users (frontend),
|
||||
// but every data endpoint here ALSO enforces the level server-side (defense in depth) — the
|
||||
// passive/probe data is diagnostic and must never be reachable by a lower-privileged session.
|
||||
public partial class IntranetController
|
||||
{
|
||||
// fds_sys authorization must exceed this to use the Admin module.
|
||||
private const int AdminMinAuthExclusive = 4;
|
||||
|
||||
private async Task<IActionResult> Do_Process_Admin(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Admin action={Action} code={Code} user={User}", id, code, UserAccountID);
|
||||
|
||||
// The auth probe is the one endpoint that answers for BOTH authorized and unauthorized
|
||||
// users (the frontend uses manage>0 to decide whether to render the module at all).
|
||||
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||
|
||||
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("Admin access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||
UserAccountID, authLevel, id);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
var status = _systemStatus;
|
||||
switch (id.ToLowerInvariant())
|
||||
{
|
||||
case "status":
|
||||
{
|
||||
var info = status.GetInfo();
|
||||
var probes = await status.ProbeAllAsync(HttpContext.RequestAborted);
|
||||
return AdminJson(new { info, probes });
|
||||
}
|
||||
|
||||
case "info":
|
||||
return AdminJson(new { info = status.GetInfo() });
|
||||
|
||||
case "probe":
|
||||
{
|
||||
// code carries the component id, e.g. /do/admin/probe/database
|
||||
string component = string.IsNullOrWhiteSpace(code) ? Form("component") : code;
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
return BadRequest400();
|
||||
var probe = await status.ProbeAsync(component, HttpContext.RequestAborted);
|
||||
return AdminJson(new { probe });
|
||||
}
|
||||
|
||||
case "testmail":
|
||||
{
|
||||
if (!HasForm("to", "subject"))
|
||||
return BadRequest400();
|
||||
var result = await status.SendTestEmailAsync(
|
||||
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
|
||||
UserAccountID, result.RequestedRecipient, result.Sent);
|
||||
return AdminJson(new { result });
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
|
||||
// The status DTOs (SystemInfoSnapshot / SystemProbeResult) are PascalCase; serialize them
|
||||
// camelCase so the Admin frontend contract matches the lowercase convention used elsewhere.
|
||||
private static readonly JsonSerializerSettings CamelCaseJson = new()
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
};
|
||||
|
||||
private ContentResult AdminJson(object payload) =>
|
||||
Content(JsonConvert.SerializeObject(payload, CamelCaseJson), "application/json");
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the calling user's <c>fds_sys</c> module authorization level via the
|
||||
/// <c>fis_getModuleAuth</c> SQL function (same mechanism as <see cref="HandleAuth"/>).
|
||||
/// Returns -3 when it cannot be determined (fail-closed).
|
||||
/// </summary>
|
||||
private async Task<int> GetSystemAdminAuthAsync(string fn, string id, string code)
|
||||
{
|
||||
var val = await getSQLValue_async<int>(
|
||||
"SELECT [dbo].[fis_getModuleAuth](@module, @authuser);",
|
||||
_intranet.Intranet__SQLConnectionString, -3,
|
||||
StdParamlist(SQL_VarChar("@module", "fds_sys")),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return val.Result;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||
private readonly IReminderDraftService _reminderDrafts;
|
||||
private readonly IDraftNotifier _draftNotifier;
|
||||
private readonly ISystemStatusService _systemStatus;
|
||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||
private readonly List<string> _allowedGet = new()
|
||||
{
|
||||
@@ -68,7 +69,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IEventService events,
|
||||
IInvoiceDraftService invoiceDrafts,
|
||||
IReminderDraftService reminderDrafts,
|
||||
IDraftNotifier draftNotifier)
|
||||
IDraftNotifier draftNotifier,
|
||||
ISystemStatusService systemStatus)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
@@ -85,6 +87,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_invoiceDrafts = invoiceDrafts;
|
||||
_reminderDrafts = reminderDrafts;
|
||||
_draftNotifier = draftNotifier;
|
||||
_systemStatus = systemStatus;
|
||||
}
|
||||
|
||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||
@@ -163,6 +166,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"rem" => await Do_Process_Reminder(fn, id, code),
|
||||
"rep" => await Do_Process_Reports(fn, id, code),
|
||||
"bam" => await Do_Process_Bankings(fn, id, code),
|
||||
"admin" => await Do_Process_Admin(fn, id, code),
|
||||
"auth" => await HandleAuth(fn, id, code),
|
||||
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
||||
"spw" => await HandleSendPassword(fn, id, code),
|
||||
|
||||
@@ -168,8 +168,9 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
||||
|
||||
### 4.3 Service Layer (Dependency Injection)
|
||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`.
|
||||
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
||||
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
|
||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/SystemStatusService.cs"
|
||||
- "Fuchs/Services/ISystemStatusService.cs"
|
||||
- "Fuchs/Services/SystemStatusModels.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
relatedDecisions:
|
||||
- "0011-admin-module-system-status.md"
|
||||
---
|
||||
|
||||
# Admin / System-Status module
|
||||
|
||||
## Summary
|
||||
The **Admin** module gives a privileged operator a live, in-app view of the running
|
||||
deployment's configuration and health, plus a test-email tool. It answers "which host am I
|
||||
on, can this instance reach SQL Server / Key Vault / Blob Storage / the MFR ERP, how is
|
||||
email wired up (including the OverrideRecipient redirect), and does sending actually work".
|
||||
Access is restricted to users whose `fds_sys` module authorization is greater than 4.
|
||||
|
||||
## How it works
|
||||
|
||||
### Authorization (two layers)
|
||||
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuIfAuthorized` calls
|
||||
`$fis.getAuth('fds_sys')`; only when the level is > 4 does it push the `init:admin`
|
||||
button into `$ocms.ocmsmenu` and re-render `#mainmenu`. Users below the threshold never
|
||||
see the button and never fetch the module script.
|
||||
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
|
||||
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
|
||||
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
|
||||
frontend can cleanly decline to render. This is defense in depth: hiding the button is
|
||||
not a security control on its own.
|
||||
|
||||
### Request flow
|
||||
```
|
||||
click "Administration" → $ocms.init('admin')
|
||||
POST /do/admin/auth → { manage, level } (manage>0 ⇒ authorized)
|
||||
load /web/fis.admin.de.js + /web/fis.admin.css
|
||||
$ocms.admin.init2() → init3() renders the page
|
||||
POST /do/admin/status → { info, probes } full snapshot + all probes
|
||||
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
|
||||
```
|
||||
|
||||
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
|
||||
- `GetInfo()` — passive snapshot, no network calls: host (machine/OS/framework/environment/
|
||||
test-deployment/uptime), database (server/catalog/login parsed from the connection string —
|
||||
**never the password**), email (mailer enabled/base-url/account/server-id/token-present +
|
||||
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
|
||||
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
|
||||
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
|
||||
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
|
||||
optional `metrics`). They never throw — failures are captured in the result. `database` runs
|
||||
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via
|
||||
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info
|
||||
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as
|
||||
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`.
|
||||
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the
|
||||
blob probe uses it for the file counts.
|
||||
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
|
||||
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
|
||||
reports the requested recipient and, when active, the override target.
|
||||
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
|
||||
`systemstatus.probe` activity span.
|
||||
|
||||
### Startup-checks widget (non-refreshable)
|
||||
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
|
||||
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
|
||||
`StartupCheckReporter`, which `GetInfo()` returns as `StartupChecks`. The Admin page renders it as a
|
||||
single **non-refreshable** card (there is no per-check retry — the live connectivity probes above
|
||||
cover on-demand re-testing; the startup card is a historical record of the boot run). When the
|
||||
self-test is disabled (`Enabled=false`, the appsettings default) or has not completed, the card shows
|
||||
a "nicht ausgeführt" note. Each item shows OK / Fehler / übersprungen (a disabled check reports
|
||||
`enabled=false`).
|
||||
|
||||
### Frontend (`fis.admin.js` + `fis.admin_txt_de.js` + `fis.admin.scss`)
|
||||
Standard lazy-loaded module (same contract as `inv`/`rep`/`bam`). Renders a system card plus a
|
||||
responsive grid of status cards; connectivity cards carry a colored status pill and a per-card
|
||||
refresh button, the email card carries the test-email dialog. The topbar has an "Alle prüfen"
|
||||
button that reloads the whole snapshot. Admin responses are serialized **camelCase** (the DTOs are
|
||||
PascalCase) so the JS reads `probe.status`, `info.database.server`, etc.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — `Do_Process_Admin` dispatch + auth gate + camelCase JSON helper.
|
||||
- `Fuchs/Services/ISystemStatusService.cs`, `SystemStatusService.cs`, `SystemStatusModels.cs` — diagnostics service + DTOs.
|
||||
- `Fuchs/Services/StartupCheckReport.cs` (`StartupCheckReporter` singleton) + `StartupSelfTestService.cs` — boot self-test result captured for the non-refreshable widget.
|
||||
- `Fuchs/Services/IBlobStorageService.cs` / `AzureBlobStorageService.cs` — `CheckConnectivityAsync` + `BlobConnectivity`.
|
||||
- `Fuchs/js/intranet/modules/fis.admin*.js`, `fis.admin.scss` — the module, texts, styles (bundled via `bdlconfig.json`).
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuIfAuthorized`) + `fis_main_go.js` — conditional menu button.
|
||||
- `Fuchs.Tests/SystemStatusServiceTests.cs` — service tests.
|
||||
|
||||
## Related decisions
|
||||
- [0011 — Admin module gated on `fds_sys` > 4](../Decisions/0011-admin-module-system-status.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/SystemStatusService.cs"
|
||||
- "Fuchs/Services/ISystemStatusService.cs"
|
||||
- "Fuchs/Services/SystemStatusModels.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0011 — Admin module gated on `fds_sys` > 4
|
||||
|
||||
## Context
|
||||
Operators needed an in-app view of the running deployment's health: which host it
|
||||
runs on, whether SQL Server / Azure Key Vault / Azure Blob Storage / the MFR ERP are
|
||||
reachable, how the email service is configured (including the dev/test
|
||||
`Fuchs:Email:OverrideRecipient` redirect), and a way to send a test email. The
|
||||
`StartupSelfTestService` already probes most of these once at startup and writes the
|
||||
result to the log — but that is invisible to a logged-in operator and cannot be re-run
|
||||
on demand. This is privileged, infrastructure-revealing information (server names,
|
||||
account names, connectivity state) that must not be exposed to ordinary users.
|
||||
|
||||
## Decision
|
||||
- Add an **Admin** module (`admin`) alongside the existing invoice/reminder/report/banking
|
||||
modules, following the same frontend module contract (`init:admin` → `admin/auth`
|
||||
returns `{ manage }` → lazy-load `/web/fis.admin.de.js` + `/web/fis.admin.css` →
|
||||
`init2()`).
|
||||
- Access is gated on the caller's **`fds_sys` module authorization being strictly greater
|
||||
than 4** (`fis_getModuleAuth('fds_sys', authuser) > 4`). The menu button is only rendered,
|
||||
and the module script only fetched, for such users; **every** Admin data endpoint
|
||||
additionally re-checks the level server-side (defense in depth) and returns 401 otherwise.
|
||||
`admin/auth` is the sole endpoint that answers for unauthorized users too (it returns
|
||||
`manage: 0`), so the frontend can decide whether to render the module at all.
|
||||
- Backend diagnostics live in a new DI-registered `ISystemStatusService` (scoped). It exposes
|
||||
a passive `GetInfo()` snapshot and live, never-throwing connectivity probes
|
||||
(`database`, `keyvault`, `blob`, `mfr`) plus `SendTestEmailAsync`. The test email goes
|
||||
through the normal `IComService` pipeline, so the `OverrideRecipient` safety net applies
|
||||
exactly as for any other outbound mail.
|
||||
- The service never returns secrets — only presence flags and non-sensitive values
|
||||
(server/catalog/login name, storage account name, error text). Responses are serialized
|
||||
camelCase to match the frontend's lowercase convention.
|
||||
|
||||
## Consequences
|
||||
- `fds_sys` is now a security-relevant authorization key: granting it a value > 4 exposes
|
||||
infrastructure status and the ability to send test emails. Provision it deliberately.
|
||||
- `SystemStatusService` is intentionally **separate** from `StartupSelfTestService` rather
|
||||
than a shared refactor: the startup service's probe methods are `protected virtual` and its
|
||||
tests override them, so folding both onto one probe surface would have broken that contract.
|
||||
The two therefore duplicate a little probe logic (Key Vault secret read, MFR `GetEntities`,
|
||||
SQL `SELECT`); keep them behaviourally aligned when either changes.
|
||||
- New probes (or new status facts) belong in `SystemStatusService` behind the same
|
||||
`SystemProbeResult` / `SystemInfoSnapshot` shapes; add the component id to
|
||||
`SystemStatusService.Components` and a card in `fis.admin.js`.
|
||||
- Blob connectivity is checked via a new `IBlobStorageService.CheckConnectivityAsync`
|
||||
(account-info request); it too never throws.
|
||||
|
||||
## Alternatives considered
|
||||
- **Reuse `StartupSelfTestService` directly.** Rejected — see Consequences; its virtual/overridden
|
||||
probe surface is owned by its tests, and it is a one-shot `BackgroundService`, not a
|
||||
request-scoped query service.
|
||||
- **Gate only in the frontend (hide the menu button).** Rejected — the endpoints would still be
|
||||
reachable by crafting the POST. Server-side enforcement on every endpoint is required.
|
||||
- **A separate `fds_admin`/new module-auth key.** Rejected — `fds_sys` already models
|
||||
system-level privilege; reusing it avoids a parallel permission to provision.
|
||||
@@ -51,6 +51,9 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobUploadsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
|
||||
public static readonly Counter<long> SystemProbes =
|
||||
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
|
||||
"Admin system-status connectivity probes executed, tagged by component and outcome.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
|
||||
@@ -123,6 +123,9 @@ public class Program
|
||||
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
||||
// module can display it as a non-refreshable widget (must be registered before the service).
|
||||
builder.Services.AddSingleton<StartupCheckReporter>();
|
||||
builder.Services.AddHostedService<StartupSelfTestService>();
|
||||
|
||||
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
||||
@@ -135,6 +138,10 @@ public class Program
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
// Read-only system diagnostics for the Admin module (config snapshot + connectivity probes
|
||||
// + test-email). Restricted to fds_sys > 4 in IntranetController.Admin; see the concept doc.
|
||||
builder.Services.AddScoped<ISystemStatusService, SystemStatusService>();
|
||||
|
||||
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
|
||||
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
|
||||
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,10 @@
|
||||
"Enabled": true
|
||||
},
|
||||
"Email": {
|
||||
"OverrideRecipient": "service@emails.processweb.de"
|
||||
"OverrideRecipient": "info@processweb.de"
|
||||
},
|
||||
"AzureStorage": {
|
||||
"Enabled": false,
|
||||
"Enabled": true,
|
||||
"InvoiceContainer": "dev-fuchs-invoices",
|
||||
"ReminderContainer": "dev-fuchs-reminders"
|
||||
}
|
||||
|
||||
@@ -151,5 +151,24 @@
|
||||
"js/intranet/modules/fis.bam.scss"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:admin",
|
||||
"outputFileName": "wwwroot/web/fis.admin.de.js",
|
||||
"inputFiles": [
|
||||
"js/intranet/modules/fis.admin_txt_de.js",
|
||||
"js/intranet/modules/fis.admin.js"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:admin",
|
||||
"outputFileName": "wwwroot/web/fis.admin.min.css",
|
||||
"inputFiles": [
|
||||
"css/intranet/oci_variables.scss",
|
||||
"css/intranet/fis_variables.scss",
|
||||
"js/intranet/modules/fis.admin.scss"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
$fis.addAdminMenuIfAuthorized();
|
||||
});
|
||||
|
||||
@@ -10,3 +10,20 @@
|
||||
//, { lbl: $t.m_efa, id: 'm_efa', fnc: 'init:efa' }
|
||||
]);
|
||||
})();
|
||||
|
||||
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
|
||||
The button is added — and only then does clicking it load the module script — after the
|
||||
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
|
||||
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
|
||||
$fis.addAdminMenuIfAuthorized = function () {
|
||||
if ($('#m_admin').length > 0) { return; } // idempotent
|
||||
$fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
|
||||
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
|
||||
/* Re-render the main menu cleanly: drop the previously generated menu list
|
||||
(never the .nav-right settings dropdown) and rebuild it from the array. */
|
||||
$('#mainmenu').children('ul:not(.nav-right)').remove();
|
||||
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
|
||||
}
|
||||
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
, m_rep: 'Berichte'
|
||||
, m_todo: 'ToDos'
|
||||
, m_bcd: 'BankBuchungen'
|
||||
, m_admin: 'Administration'
|
||||
, rsp: 'Passwort ändern'
|
||||
, pnm: 'Die Passwörter stimmen nicht überein'
|
||||
, cps: 'Das neue Passwort wurde gespeichert.'
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
let $adm = {
|
||||
init2: function () {
|
||||
// No external libraries needed — go straight to render.
|
||||
$ocms.getScript([], function () { $adm.init3(); });
|
||||
},
|
||||
init3: function () {
|
||||
$fis.cf(true);
|
||||
$fis.lf(true);
|
||||
$('#activemodule').text($adt.mdl);
|
||||
$adm.topbar();
|
||||
let frm = $$.dc('admfrm', $('#contentframe')).ldng(1);
|
||||
$adm.load(frm);
|
||||
},
|
||||
topbar: function () {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
|
||||
]);
|
||||
},
|
||||
load: function (frm) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/status'), success: function (r) {
|
||||
frm.empty();
|
||||
$adm.render(frm, r.info || {}, r.probes || []);
|
||||
}, error: function () {
|
||||
frm.empty();
|
||||
$$.dc('adm_err', frm).text($adt.loadfail);
|
||||
}, complete: function () { frm.ldng(0); }
|
||||
});
|
||||
},
|
||||
render: function (frm, info, probes) {
|
||||
let pmap = {};
|
||||
$.each(probes, function (i, p) { pmap[p.component] = p; });
|
||||
|
||||
$$.dc('adm_sec', frm).text($adt.sub_info);
|
||||
$adm.sysCard(frm, info);
|
||||
$adm.startupCard(frm, info.startupChecks);
|
||||
|
||||
$$.dc('adm_sec', frm).text($adt.sub_status);
|
||||
let grid = $$.dc('adm_grid', frm);
|
||||
$adm.dbCard(grid, info.database || {}, pmap.database);
|
||||
$adm.mailCard(grid, info.email || {});
|
||||
$adm.blobCard(grid, info.blob || {}, pmap.blob);
|
||||
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
|
||||
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
|
||||
},
|
||||
|
||||
/* ── generic building blocks ─────────────────────────────────────────── */
|
||||
kvTable: function (parent, rows) {
|
||||
let t = $$.tbl().addClass('adm_kv');
|
||||
$.each(rows || [], function (i, r) {
|
||||
if (!r) { return true; }
|
||||
let tr = $$.tr(t);
|
||||
$$.td(tr).addClass('k').text(r[0] == null ? '' : String(r[0]));
|
||||
let v = $$.td(tr).addClass('v');
|
||||
if (r[1] instanceof jQuery) { v.append(r[1]); } else { v.text(r[1] == null ? '' : String(r[1])); }
|
||||
});
|
||||
if (parent instanceof jQuery) { parent.append(t); }
|
||||
return t;
|
||||
},
|
||||
yesNo: function (b) {
|
||||
return $$.sc('adm_bool adm_bool_' + (b ? 'y' : 'n'), b ? $adt.yes : $adt.no);
|
||||
},
|
||||
setPill: function (pill, probe) {
|
||||
probe = probe || {};
|
||||
let st = probe.status || 'unconfigured';
|
||||
let lbl = {
|
||||
ok: $adt.st_ok, error: $adt.st_error, disabled: $adt.st_disabled,
|
||||
unconfigured: $adt.st_unconfigured, checking: $adt.st_checking
|
||||
}[st] || st;
|
||||
pill.attr('class', 'adm_pill adm_pill_' + st).text(lbl);
|
||||
return pill;
|
||||
},
|
||||
|
||||
/* Connectivity card with a live status pill + per-card refresh button. */
|
||||
probeCard: function (grid, title, component, cfgRows, probe) {
|
||||
let card = $$.dc('adm_card', grid);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s(title).appendTo(hd);
|
||||
let pill = $$.sc('adm_pill').appendTo(hd);
|
||||
let rbtn = $$.bbtn($adt.refresh, 'adm_btn').appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
$adm.kvTable(bd, cfgRows);
|
||||
|
||||
let pf = $$.dc('adm_probe', bd);
|
||||
let msg = $$.dc('adm_probe_msg', pf);
|
||||
let met = $$.dc('adm_probe_metrics', pf);
|
||||
let det = $$.dc('adm_probe_det', pf);
|
||||
let dur = $$.dc('adm_probe_dur', pf);
|
||||
let apply = function (p) {
|
||||
p = p || {};
|
||||
$adm.setPill(pill, p);
|
||||
msg.text(p.message || '');
|
||||
met.empty();
|
||||
$.each(p.metrics || [], function (i, m) {
|
||||
let row = $$.dc('adm_metric', met);
|
||||
$$.sc('adm_metric_k').text(m.label || '').appendTo(row);
|
||||
$$.sc('adm_metric_v').text(m.value == null ? '' : String(m.value)).appendTo(row);
|
||||
});
|
||||
det.text(p.detail || '');
|
||||
dur.text(p.durationMs != null ? (p.durationMs + ' ms') : '');
|
||||
};
|
||||
apply(probe);
|
||||
|
||||
rbtn.click(function () {
|
||||
$adm.setPill(pill, { status: 'checking' });
|
||||
msg.text(''); met.empty(); det.text(''); dur.text('');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/probe/' + component), success: function (r) {
|
||||
apply(r.probe);
|
||||
}, error: function () {
|
||||
$adm.setPill(pill, { status: 'error' });
|
||||
msg.text($adt.st_error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return card;
|
||||
},
|
||||
|
||||
/* ── individual cards ────────────────────────────────────────────────── */
|
||||
sysCard: function (frm, info) {
|
||||
let card = $$.dc('adm_card adm_card_wide', frm);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.host).appendTo(hd);
|
||||
let env = $$.sc('adm_env').text(info.environment || '');
|
||||
if (info.isTestDeployment === true) { env.aC('adm_env_test'); }
|
||||
env.appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
$adm.kvTable(bd, [
|
||||
[$adt.machine, info.machineName],
|
||||
[$adt.os, info.osDescription],
|
||||
[$adt.framework, info.frameworkDescription],
|
||||
[$adt.env, info.environment],
|
||||
[$adt.testdep, $adm.yesNo(info.isTestDeployment === true)],
|
||||
[$adt.pid, info.processId],
|
||||
[$adt.since, info.processStartUtc ? fdt(info.processStartUtc) : ''],
|
||||
[$adt.uptime, (info.uptimeHours != null ? (info.uptimeHours + ' ' + $adt.hours) : '')]
|
||||
]);
|
||||
},
|
||||
/* Non-refreshable widget: the one-shot startup self-test result captured at boot. */
|
||||
startupCard: function (frm, sc) {
|
||||
let card = $$.dc('adm_card adm_card_wide', frm);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.startup).appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
|
||||
if (!sc || sc.ran !== true) {
|
||||
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: 'disabled' });
|
||||
$$.dc('adm_note', bd).text($adt.startup_notrun);
|
||||
return;
|
||||
}
|
||||
|
||||
let items = sc.items || [];
|
||||
let anyFail = false;
|
||||
$.each(items, function (i, it) { if (it.enabled === true && it.ok !== true) { anyFail = true; } });
|
||||
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: anyFail ? 'error' : 'ok' });
|
||||
|
||||
$adm.kvTable(bd, [
|
||||
[$adt.startup_on, sc.machineName],
|
||||
[$adt.startup_at, sc.completedUtc ? fdt(sc.completedUtc) : '']
|
||||
]);
|
||||
|
||||
let list = $$.dc('adm_checks', bd);
|
||||
$.each(items, function (i, it) {
|
||||
let row = $$.dc('adm_check', list);
|
||||
$$.sc('adm_check_k').text($adt['chk_' + it.name] || it.name).appendTo(row);
|
||||
let stt = it.enabled !== true ? 'skipped' : (it.ok === true ? 'ok' : 'fail');
|
||||
let lbl = { ok: $adt.chk_ok, fail: $adt.chk_fail, skipped: $adt.chk_skipped }[stt];
|
||||
$$.sc('adm_check_v adm_check_' + stt).text(lbl).appendTo(row);
|
||||
});
|
||||
},
|
||||
dbCard: function (grid, d, probe) {
|
||||
$adm.probeCard(grid, $adt.db, 'database', [
|
||||
[$adt.configured, $adm.yesNo(d.configured === true)],
|
||||
[$adt.server, d.server],
|
||||
[$adt.catalog, d.catalog],
|
||||
[$adt.login, d.userId]
|
||||
], probe);
|
||||
},
|
||||
mailCard: function (grid, e) {
|
||||
let card = $$.dc('adm_card', grid);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.mail).appendTo(hd);
|
||||
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: e.mailerEnabled === true ? 'ok' : 'disabled' });
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
let rows = [
|
||||
[$adt.mailer, $adm.yesNo(e.mailerEnabled === true)],
|
||||
[$adt.baseurl, e.baseUrl],
|
||||
[$adt.account, e.accountId],
|
||||
[$adt.serverid, e.serverId],
|
||||
[$adt.token, $adm.yesNo(e.tokenConfigured === true)],
|
||||
[$adt.overrideActive, $adm.yesNo(e.overrideActive === true)]
|
||||
];
|
||||
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
|
||||
$adm.kvTable(bd, rows);
|
||||
let ft = $$.dc('adm_card_ft', card);
|
||||
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
|
||||
},
|
||||
blobCard: function (grid, b, probe) {
|
||||
$adm.probeCard(grid, $adt.blob, 'blob', [
|
||||
[$adt.enabled, $adm.yesNo(b.enabled === true)],
|
||||
[$adt.configured, $adm.yesNo(b.configured === true)],
|
||||
[$adt.invContainer, b.invoiceContainer],
|
||||
[$adt.remContainer, b.reminderContainer]
|
||||
], probe);
|
||||
},
|
||||
mfrCard: function (grid, m, probe) {
|
||||
$adm.probeCard(grid, $adt.mfr, 'mfr', [
|
||||
[$adt.host_l, m.host],
|
||||
[$adt.creds, $adm.yesNo(m.credentialsConfigured === true)],
|
||||
[$adt.sync, $adm.yesNo(m.syncEnabled === true)]
|
||||
], probe);
|
||||
},
|
||||
kvCard: function (grid, k, probe) {
|
||||
$adm.probeCard(grid, $adt.kv, 'keyvault', [
|
||||
[$adt.vault, k.vaultUri],
|
||||
[$adt.appname, k.appName],
|
||||
[$adt.seccount, k.managedSecretCount],
|
||||
[$adt.clientreg, $adm.yesNo(k.clientRegistered === true)]
|
||||
], probe);
|
||||
},
|
||||
|
||||
/* ── test email ──────────────────────────────────────────────────────── */
|
||||
testMailDlg: function () {
|
||||
$ocms.dlgform([
|
||||
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
|
||||
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
|
||||
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
|
||||
], {
|
||||
title: $adt.testmail,
|
||||
button: $adt.testmail_btn,
|
||||
size: [420, 560],
|
||||
addcontent: $$.dc('adm_note').text($adt.tm_hint),
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/testmail'), data: qs, timeout: 60000, success: function (r) {
|
||||
let res = r.result || {};
|
||||
alert(res.message || (res.sent ? $adt.tm_sent : $adt.tm_failed));
|
||||
c.trigger('modal_close');
|
||||
}, error: function () {
|
||||
alert($adt.tm_failed);
|
||||
}, complete: function () { c.ldng(0); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
let $$adm = { init2: $adm.init2, auth: {} };
|
||||
export default $$adm;
|
||||
@@ -0,0 +1,200 @@
|
||||
.admfrm {
|
||||
padding: 1.5rem;
|
||||
|
||||
.adm_sec {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: $fuchs_blau;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
border-bottom: 2px solid $fuchs_blau;
|
||||
padding-bottom: 0.25rem;
|
||||
|
||||
&:first-child { margin-top: 0; }
|
||||
}
|
||||
|
||||
.adm_err {
|
||||
padding: 1rem;
|
||||
color: #b00020;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.adm_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.adm_card {
|
||||
border: 1px solid #d5d5d5;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
|
||||
overflow: hidden;
|
||||
|
||||
&.adm_card_wide { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.adm_card_hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: $fuchs_blau;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
|
||||
> span:first-child { flex: 1 1 auto; }
|
||||
}
|
||||
|
||||
.adm_card_bd {
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
|
||||
.adm_card_ft {
|
||||
padding: 0.5rem 0.85rem 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table.adm_kv {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
td {
|
||||
padding: 0.25rem 0.35rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: top;
|
||||
font-size: 0.92rem;
|
||||
|
||||
&.k { color: #666; white-space: nowrap; width: 40%; }
|
||||
&.v { word-break: break-word; }
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
}
|
||||
|
||||
.adm_probe {
|
||||
margin-top: 0.6rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #ddd;
|
||||
font-size: 0.88rem;
|
||||
|
||||
.adm_probe_msg { font-weight: 600; }
|
||||
.adm_probe_det { color: #555; word-break: break-word; margin-top: 0.15rem; }
|
||||
.adm_probe_dur { color: #999; margin-top: 0.15rem; font-size: 0.8rem; }
|
||||
|
||||
.adm_probe_metrics {
|
||||
margin-top: 0.35rem;
|
||||
|
||||
.adm_metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0;
|
||||
border-bottom: 1px dotted #eee;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.adm_metric_k { color: #555; word-break: break-word; }
|
||||
.adm_metric_v { font-weight: 600; white-space: nowrap; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* startup checks list */
|
||||
.adm_checks {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.adm_check {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.adm_check_k { color: #333; }
|
||||
.adm_check_v {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.adm_check_ok { background: #e2f3da; color: #2f6d18; }
|
||||
.adm_check_fail { background: #f3ded9; color: #c0341d; }
|
||||
.adm_check_skipped { background: #ececec; color: #777; }
|
||||
}
|
||||
}
|
||||
|
||||
/* status pill */
|
||||
.adm_pill {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.adm_pill_ok { background: $fuchs_akzent; }
|
||||
.adm_pill_error { background: #c0341d; }
|
||||
.adm_pill_disabled { background: #888; }
|
||||
.adm_pill_unconfigured { background: #d69100; }
|
||||
.adm_pill_checking { background: #2a72c4; }
|
||||
|
||||
/* boolean chips */
|
||||
.adm_bool {
|
||||
display: inline-block;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.adm_bool_y { background: #e2f3da; color: #2f6d18; }
|
||||
.adm_bool_n { background: #f3e0dd; color: #9c2a17; }
|
||||
|
||||
.adm_env {
|
||||
font-size: 0.8rem;
|
||||
font-weight: normal;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
|
||||
&.adm_env_test { background: #13a143; }
|
||||
}
|
||||
|
||||
.adm_btn {
|
||||
cursor: pointer;
|
||||
border-radius: 0.28rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #ababab;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
|
||||
&.adm_btn_primary {
|
||||
background-color: $fuchs_akzent;
|
||||
border-color: darken($fuchs_akzent, 8%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.adm_card_hd .adm_btn {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.adm_note {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #f3f6fb;
|
||||
border-left: 3px solid $fuchs_blau;
|
||||
font-size: 0.85rem;
|
||||
color: #444;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
let $adt = {
|
||||
mdl: 'Administration'
|
||||
, sub_info: 'System'
|
||||
, sub_status: 'Status & Konnektivität'
|
||||
, sub_tests: 'Tests'
|
||||
// startup checks widget (non-refreshable)
|
||||
, startup: 'Startup-Prüfungen'
|
||||
, startup_notrun: 'Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).'
|
||||
, startup_on: 'Host'
|
||||
, startup_at: 'Ausgeführt (UTC)'
|
||||
, chk_ok: 'OK'
|
||||
, chk_fail: 'Fehler'
|
||||
, chk_skipped: 'übersprungen'
|
||||
, chk_KeyVault: 'Azure Key Vault'
|
||||
, chk_Database: 'SQL-Datenbank'
|
||||
, chk_MFR: 'MFR (ERP)'
|
||||
, chk_PdfLicense: 'Spire.PDF-Lizenz'
|
||||
, chk_Mailer: 'E-Mail (Startup-Probe)'
|
||||
// system info
|
||||
, host: 'Host / Server'
|
||||
, machine: 'Hostname'
|
||||
, os: 'Betriebssystem'
|
||||
, framework: 'Framework'
|
||||
, env: 'Umgebung'
|
||||
, testdep: 'Test-Deployment'
|
||||
, pid: 'Prozess-ID'
|
||||
, uptime: 'Laufzeit'
|
||||
, since: 'Start (UTC)'
|
||||
, hours: 'Std.'
|
||||
// status cards
|
||||
, db: 'SQL-Datenbank'
|
||||
, mail: 'E-Mail-Dienst'
|
||||
, blob: 'Azure Blob Storage'
|
||||
, mfr: 'MFR (ERP)'
|
||||
, kv: 'Azure Key Vault'
|
||||
// labels
|
||||
, server: 'Server'
|
||||
, catalog: 'Datenbank'
|
||||
, login: 'Login'
|
||||
, mailer: 'Mailer aktiv'
|
||||
, baseurl: 'Base-URL'
|
||||
, account: 'Account'
|
||||
, serverid: 'Server-ID'
|
||||
, token: 'Token hinterlegt'
|
||||
, override: 'OverrideRecipient'
|
||||
, overrideActive: 'Umleitung aktiv'
|
||||
, enabled: 'Aktiviert'
|
||||
, configured: 'Konfiguriert'
|
||||
, invContainer: 'Rechnungs-Container'
|
||||
, remContainer: 'Mahnungs-Container'
|
||||
, host_l: 'Host'
|
||||
, creds: 'Zugangsdaten hinterlegt'
|
||||
, sync: 'Sync aktiv'
|
||||
, vault: 'Vault-URI'
|
||||
, appname: 'App-Präfix'
|
||||
, seccount: 'Verwaltete Secrets'
|
||||
, clientreg: 'Client registriert'
|
||||
, duration: 'Dauer'
|
||||
, checked: 'Geprüft'
|
||||
, detail: 'Detail'
|
||||
// status values
|
||||
, st_ok: 'OK'
|
||||
, st_error: 'Fehler'
|
||||
, st_disabled: 'Deaktiviert'
|
||||
, st_unconfigured: 'Nicht konfiguriert'
|
||||
, st_checking: 'Prüfe…'
|
||||
// actions
|
||||
, refresh: 'Aktualisieren'
|
||||
, refreshAll: 'Alle prüfen'
|
||||
, yes: 'Ja'
|
||||
, no: 'Nein'
|
||||
// test email
|
||||
, testmail: 'Test-E-Mail'
|
||||
, testmail_btn: 'Test-E-Mail senden'
|
||||
, tm_to: 'Empfänger'
|
||||
, tm_subject: 'Betreff'
|
||||
, tm_body: 'Nachricht'
|
||||
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
|
||||
, tm_sent: 'Test-E-Mail übergeben.'
|
||||
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
|
||||
, tm_default_subject: 'Fuchs Intranet – Test-E-Mail'
|
||||
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
|
||||
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
/* basics */
|
||||
/* Color SCHEME */
|
||||
/* #8396bd */
|
||||
/* #e3e6e6 */
|
||||
/* #ffffff */
|
||||
/* #262626 */
|
||||
/* #262626 */
|
||||
/* #e3e6e6 */
|
||||
/* #FFC801 */
|
||||
/* #ffff4a */
|
||||
/* media breaks */
|
||||
/* other */
|
||||
/* mixin */
|
||||
/* basics */
|
||||
/* Color SCHEME */
|
||||
/* #1b4379 #0033b3 */
|
||||
/*neu: #56a532 ; alt: rgb(32,144,119);*/
|
||||
/* 10% sw */
|
||||
.admfrm {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.admfrm .adm_sec {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: rgb(27, 67, 121);
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
border-bottom: 2px solid rgb(27, 67, 121);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.admfrm .adm_sec:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.admfrm .adm_err {
|
||||
padding: 1rem;
|
||||
color: #b00020;
|
||||
font-weight: bold;
|
||||
}
|
||||
.admfrm .adm_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.admfrm .adm_card {
|
||||
border: 1px solid #d5d5d5;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
.admfrm .adm_card.adm_card_wide {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.admfrm .adm_card_hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgb(27, 67, 121);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
.admfrm .adm_card_hd > span:first-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.admfrm .adm_card_bd {
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
.admfrm .adm_card_ft {
|
||||
padding: 0.5rem 0.85rem 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
.admfrm table.adm_kv {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.admfrm table.adm_kv td {
|
||||
padding: 0.25rem 0.35rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: top;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.admfrm table.adm_kv td.k {
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
width: 40%;
|
||||
}
|
||||
.admfrm table.adm_kv td.v {
|
||||
word-break: break-word;
|
||||
}
|
||||
.admfrm table.adm_kv tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.admfrm .adm_probe {
|
||||
margin-top: 0.6rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #ddd;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_msg {
|
||||
font-weight: 600;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_det {
|
||||
color: #555;
|
||||
word-break: break-word;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_dur {
|
||||
color: #999;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_metrics {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_metrics .adm_metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0;
|
||||
border-bottom: 1px dotted #eee;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_metrics .adm_metric:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_k {
|
||||
color: #555;
|
||||
word-break: break-word;
|
||||
}
|
||||
.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_v {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admfrm {
|
||||
/* startup checks list */
|
||||
}
|
||||
.admfrm .adm_checks {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check .adm_check_k {
|
||||
color: #333;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check .adm_check_v {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.05rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check .adm_check_ok {
|
||||
background: #e2f3da;
|
||||
color: #2f6d18;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check .adm_check_fail {
|
||||
background: #f3ded9;
|
||||
color: #c0341d;
|
||||
}
|
||||
.admfrm .adm_checks .adm_check .adm_check_skipped {
|
||||
background: #ececec;
|
||||
color: #777;
|
||||
}
|
||||
.admfrm {
|
||||
/* status pill */
|
||||
}
|
||||
.admfrm .adm_pill {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admfrm .adm_pill_ok {
|
||||
background: rgb(86, 165, 50);
|
||||
}
|
||||
.admfrm .adm_pill_error {
|
||||
background: #c0341d;
|
||||
}
|
||||
.admfrm .adm_pill_disabled {
|
||||
background: #888;
|
||||
}
|
||||
.admfrm .adm_pill_unconfigured {
|
||||
background: #d69100;
|
||||
}
|
||||
.admfrm .adm_pill_checking {
|
||||
background: #2a72c4;
|
||||
}
|
||||
.admfrm {
|
||||
/* boolean chips */
|
||||
}
|
||||
.admfrm .adm_bool {
|
||||
display: inline-block;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.admfrm .adm_bool_y {
|
||||
background: #e2f3da;
|
||||
color: #2f6d18;
|
||||
}
|
||||
.admfrm .adm_bool_n {
|
||||
background: #f3e0dd;
|
||||
color: #9c2a17;
|
||||
}
|
||||
.admfrm .adm_env {
|
||||
font-size: 0.8rem;
|
||||
font-weight: normal;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.admfrm .adm_env.adm_env_test {
|
||||
background: #13a143;
|
||||
}
|
||||
.admfrm .adm_btn {
|
||||
cursor: pointer;
|
||||
border-radius: 0.28rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #ababab;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
}
|
||||
.admfrm .adm_btn.adm_btn_primary {
|
||||
background-color: rgb(86, 165, 50);
|
||||
border-color: rgb(69.68, 133.688372093, 40.511627907);
|
||||
color: #fff;
|
||||
}
|
||||
.admfrm .adm_card_hd .adm_btn {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.adm_note {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #f3f6fb;
|
||||
border-left: 3px solid rgb(27, 67, 121);
|
||||
font-size: 0.85rem;
|
||||
color: #444;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
let $adt = {
|
||||
mdl: 'Administration'
|
||||
, sub_info: 'System'
|
||||
, sub_status: 'Status & Konnektivität'
|
||||
, sub_tests: 'Tests'
|
||||
// startup checks widget (non-refreshable)
|
||||
, startup: 'Startup-Prüfungen'
|
||||
, startup_notrun: 'Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).'
|
||||
, startup_on: 'Host'
|
||||
, startup_at: 'Ausgeführt (UTC)'
|
||||
, chk_ok: 'OK'
|
||||
, chk_fail: 'Fehler'
|
||||
, chk_skipped: 'übersprungen'
|
||||
, chk_KeyVault: 'Azure Key Vault'
|
||||
, chk_Database: 'SQL-Datenbank'
|
||||
, chk_MFR: 'MFR (ERP)'
|
||||
, chk_PdfLicense: 'Spire.PDF-Lizenz'
|
||||
, chk_Mailer: 'E-Mail (Startup-Probe)'
|
||||
// system info
|
||||
, host: 'Host / Server'
|
||||
, machine: 'Hostname'
|
||||
, os: 'Betriebssystem'
|
||||
, framework: 'Framework'
|
||||
, env: 'Umgebung'
|
||||
, testdep: 'Test-Deployment'
|
||||
, pid: 'Prozess-ID'
|
||||
, uptime: 'Laufzeit'
|
||||
, since: 'Start (UTC)'
|
||||
, hours: 'Std.'
|
||||
// status cards
|
||||
, db: 'SQL-Datenbank'
|
||||
, mail: 'E-Mail-Dienst'
|
||||
, blob: 'Azure Blob Storage'
|
||||
, mfr: 'MFR (ERP)'
|
||||
, kv: 'Azure Key Vault'
|
||||
// labels
|
||||
, server: 'Server'
|
||||
, catalog: 'Datenbank'
|
||||
, login: 'Login'
|
||||
, mailer: 'Mailer aktiv'
|
||||
, baseurl: 'Base-URL'
|
||||
, account: 'Account'
|
||||
, serverid: 'Server-ID'
|
||||
, token: 'Token hinterlegt'
|
||||
, override: 'OverrideRecipient'
|
||||
, overrideActive: 'Umleitung aktiv'
|
||||
, enabled: 'Aktiviert'
|
||||
, configured: 'Konfiguriert'
|
||||
, invContainer: 'Rechnungs-Container'
|
||||
, remContainer: 'Mahnungs-Container'
|
||||
, host_l: 'Host'
|
||||
, creds: 'Zugangsdaten hinterlegt'
|
||||
, sync: 'Sync aktiv'
|
||||
, vault: 'Vault-URI'
|
||||
, appname: 'App-Präfix'
|
||||
, seccount: 'Verwaltete Secrets'
|
||||
, clientreg: 'Client registriert'
|
||||
, duration: 'Dauer'
|
||||
, checked: 'Geprüft'
|
||||
, detail: 'Detail'
|
||||
// status values
|
||||
, st_ok: 'OK'
|
||||
, st_error: 'Fehler'
|
||||
, st_disabled: 'Deaktiviert'
|
||||
, st_unconfigured: 'Nicht konfiguriert'
|
||||
, st_checking: 'Prüfe…'
|
||||
// actions
|
||||
, refresh: 'Aktualisieren'
|
||||
, refreshAll: 'Alle prüfen'
|
||||
, yes: 'Ja'
|
||||
, no: 'Nein'
|
||||
// test email
|
||||
, testmail: 'Test-E-Mail'
|
||||
, testmail_btn: 'Test-E-Mail senden'
|
||||
, tm_to: 'Empfänger'
|
||||
, tm_subject: 'Betreff'
|
||||
, tm_body: 'Nachricht'
|
||||
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
|
||||
, tm_sent: 'Test-E-Mail übergeben.'
|
||||
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
|
||||
, tm_default_subject: 'Fuchs Intranet – Test-E-Mail'
|
||||
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
|
||||
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
|
||||
};
|
||||
|
||||
let $adm = {
|
||||
init2: function () {
|
||||
// No external libraries needed — go straight to render.
|
||||
$ocms.getScript([], function () { $adm.init3(); });
|
||||
},
|
||||
init3: function () {
|
||||
$fis.cf(true);
|
||||
$fis.lf(true);
|
||||
$('#activemodule').text($adt.mdl);
|
||||
$adm.topbar();
|
||||
let frm = $$.dc('admfrm', $('#contentframe')).ldng(1);
|
||||
$adm.load(frm);
|
||||
},
|
||||
topbar: function () {
|
||||
return $('#topbar').ocmsmenu([
|
||||
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
|
||||
]);
|
||||
},
|
||||
load: function (frm) {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/status'), success: function (r) {
|
||||
frm.empty();
|
||||
$adm.render(frm, r.info || {}, r.probes || []);
|
||||
}, error: function () {
|
||||
frm.empty();
|
||||
$$.dc('adm_err', frm).text($adt.loadfail);
|
||||
}, complete: function () { frm.ldng(0); }
|
||||
});
|
||||
},
|
||||
render: function (frm, info, probes) {
|
||||
let pmap = {};
|
||||
$.each(probes, function (i, p) { pmap[p.component] = p; });
|
||||
|
||||
$$.dc('adm_sec', frm).text($adt.sub_info);
|
||||
$adm.sysCard(frm, info);
|
||||
$adm.startupCard(frm, info.startupChecks);
|
||||
|
||||
$$.dc('adm_sec', frm).text($adt.sub_status);
|
||||
let grid = $$.dc('adm_grid', frm);
|
||||
$adm.dbCard(grid, info.database || {}, pmap.database);
|
||||
$adm.mailCard(grid, info.email || {});
|
||||
$adm.blobCard(grid, info.blob || {}, pmap.blob);
|
||||
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
|
||||
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
|
||||
},
|
||||
|
||||
/* ── generic building blocks ─────────────────────────────────────────── */
|
||||
kvTable: function (parent, rows) {
|
||||
let t = $$.tbl().addClass('adm_kv');
|
||||
$.each(rows || [], function (i, r) {
|
||||
if (!r) { return true; }
|
||||
let tr = $$.tr(t);
|
||||
$$.td(tr).addClass('k').text(r[0] == null ? '' : String(r[0]));
|
||||
let v = $$.td(tr).addClass('v');
|
||||
if (r[1] instanceof jQuery) { v.append(r[1]); } else { v.text(r[1] == null ? '' : String(r[1])); }
|
||||
});
|
||||
if (parent instanceof jQuery) { parent.append(t); }
|
||||
return t;
|
||||
},
|
||||
yesNo: function (b) {
|
||||
return $$.sc('adm_bool adm_bool_' + (b ? 'y' : 'n'), b ? $adt.yes : $adt.no);
|
||||
},
|
||||
setPill: function (pill, probe) {
|
||||
probe = probe || {};
|
||||
let st = probe.status || 'unconfigured';
|
||||
let lbl = {
|
||||
ok: $adt.st_ok, error: $adt.st_error, disabled: $adt.st_disabled,
|
||||
unconfigured: $adt.st_unconfigured, checking: $adt.st_checking
|
||||
}[st] || st;
|
||||
pill.attr('class', 'adm_pill adm_pill_' + st).text(lbl);
|
||||
return pill;
|
||||
},
|
||||
|
||||
/* Connectivity card with a live status pill + per-card refresh button. */
|
||||
probeCard: function (grid, title, component, cfgRows, probe) {
|
||||
let card = $$.dc('adm_card', grid);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s(title).appendTo(hd);
|
||||
let pill = $$.sc('adm_pill').appendTo(hd);
|
||||
let rbtn = $$.bbtn($adt.refresh, 'adm_btn').appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
$adm.kvTable(bd, cfgRows);
|
||||
|
||||
let pf = $$.dc('adm_probe', bd);
|
||||
let msg = $$.dc('adm_probe_msg', pf);
|
||||
let met = $$.dc('adm_probe_metrics', pf);
|
||||
let det = $$.dc('adm_probe_det', pf);
|
||||
let dur = $$.dc('adm_probe_dur', pf);
|
||||
let apply = function (p) {
|
||||
p = p || {};
|
||||
$adm.setPill(pill, p);
|
||||
msg.text(p.message || '');
|
||||
met.empty();
|
||||
$.each(p.metrics || [], function (i, m) {
|
||||
let row = $$.dc('adm_metric', met);
|
||||
$$.sc('adm_metric_k').text(m.label || '').appendTo(row);
|
||||
$$.sc('adm_metric_v').text(m.value == null ? '' : String(m.value)).appendTo(row);
|
||||
});
|
||||
det.text(p.detail || '');
|
||||
dur.text(p.durationMs != null ? (p.durationMs + ' ms') : '');
|
||||
};
|
||||
apply(probe);
|
||||
|
||||
rbtn.click(function () {
|
||||
$adm.setPill(pill, { status: 'checking' });
|
||||
msg.text(''); met.empty(); det.text(''); dur.text('');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/probe/' + component), success: function (r) {
|
||||
apply(r.probe);
|
||||
}, error: function () {
|
||||
$adm.setPill(pill, { status: 'error' });
|
||||
msg.text($adt.st_error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return card;
|
||||
},
|
||||
|
||||
/* ── individual cards ────────────────────────────────────────────────── */
|
||||
sysCard: function (frm, info) {
|
||||
let card = $$.dc('adm_card adm_card_wide', frm);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.host).appendTo(hd);
|
||||
let env = $$.sc('adm_env').text(info.environment || '');
|
||||
if (info.isTestDeployment === true) { env.aC('adm_env_test'); }
|
||||
env.appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
$adm.kvTable(bd, [
|
||||
[$adt.machine, info.machineName],
|
||||
[$adt.os, info.osDescription],
|
||||
[$adt.framework, info.frameworkDescription],
|
||||
[$adt.env, info.environment],
|
||||
[$adt.testdep, $adm.yesNo(info.isTestDeployment === true)],
|
||||
[$adt.pid, info.processId],
|
||||
[$adt.since, info.processStartUtc ? fdt(info.processStartUtc) : ''],
|
||||
[$adt.uptime, (info.uptimeHours != null ? (info.uptimeHours + ' ' + $adt.hours) : '')]
|
||||
]);
|
||||
},
|
||||
/* Non-refreshable widget: the one-shot startup self-test result captured at boot. */
|
||||
startupCard: function (frm, sc) {
|
||||
let card = $$.dc('adm_card adm_card_wide', frm);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.startup).appendTo(hd);
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
|
||||
if (!sc || sc.ran !== true) {
|
||||
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: 'disabled' });
|
||||
$$.dc('adm_note', bd).text($adt.startup_notrun);
|
||||
return;
|
||||
}
|
||||
|
||||
let items = sc.items || [];
|
||||
let anyFail = false;
|
||||
$.each(items, function (i, it) { if (it.enabled === true && it.ok !== true) { anyFail = true; } });
|
||||
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: anyFail ? 'error' : 'ok' });
|
||||
|
||||
$adm.kvTable(bd, [
|
||||
[$adt.startup_on, sc.machineName],
|
||||
[$adt.startup_at, sc.completedUtc ? fdt(sc.completedUtc) : '']
|
||||
]);
|
||||
|
||||
let list = $$.dc('adm_checks', bd);
|
||||
$.each(items, function (i, it) {
|
||||
let row = $$.dc('adm_check', list);
|
||||
$$.sc('adm_check_k').text($adt['chk_' + it.name] || it.name).appendTo(row);
|
||||
let stt = it.enabled !== true ? 'skipped' : (it.ok === true ? 'ok' : 'fail');
|
||||
let lbl = { ok: $adt.chk_ok, fail: $adt.chk_fail, skipped: $adt.chk_skipped }[stt];
|
||||
$$.sc('adm_check_v adm_check_' + stt).text(lbl).appendTo(row);
|
||||
});
|
||||
},
|
||||
dbCard: function (grid, d, probe) {
|
||||
$adm.probeCard(grid, $adt.db, 'database', [
|
||||
[$adt.configured, $adm.yesNo(d.configured === true)],
|
||||
[$adt.server, d.server],
|
||||
[$adt.catalog, d.catalog],
|
||||
[$adt.login, d.userId]
|
||||
], probe);
|
||||
},
|
||||
mailCard: function (grid, e) {
|
||||
let card = $$.dc('adm_card', grid);
|
||||
let hd = $$.dc('adm_card_hd', card);
|
||||
$$.s($adt.mail).appendTo(hd);
|
||||
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: e.mailerEnabled === true ? 'ok' : 'disabled' });
|
||||
let bd = $$.dc('adm_card_bd', card);
|
||||
let rows = [
|
||||
[$adt.mailer, $adm.yesNo(e.mailerEnabled === true)],
|
||||
[$adt.baseurl, e.baseUrl],
|
||||
[$adt.account, e.accountId],
|
||||
[$adt.serverid, e.serverId],
|
||||
[$adt.token, $adm.yesNo(e.tokenConfigured === true)],
|
||||
[$adt.overrideActive, $adm.yesNo(e.overrideActive === true)]
|
||||
];
|
||||
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
|
||||
$adm.kvTable(bd, rows);
|
||||
let ft = $$.dc('adm_card_ft', card);
|
||||
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
|
||||
},
|
||||
blobCard: function (grid, b, probe) {
|
||||
$adm.probeCard(grid, $adt.blob, 'blob', [
|
||||
[$adt.enabled, $adm.yesNo(b.enabled === true)],
|
||||
[$adt.configured, $adm.yesNo(b.configured === true)],
|
||||
[$adt.invContainer, b.invoiceContainer],
|
||||
[$adt.remContainer, b.reminderContainer]
|
||||
], probe);
|
||||
},
|
||||
mfrCard: function (grid, m, probe) {
|
||||
$adm.probeCard(grid, $adt.mfr, 'mfr', [
|
||||
[$adt.host_l, m.host],
|
||||
[$adt.creds, $adm.yesNo(m.credentialsConfigured === true)],
|
||||
[$adt.sync, $adm.yesNo(m.syncEnabled === true)]
|
||||
], probe);
|
||||
},
|
||||
kvCard: function (grid, k, probe) {
|
||||
$adm.probeCard(grid, $adt.kv, 'keyvault', [
|
||||
[$adt.vault, k.vaultUri],
|
||||
[$adt.appname, k.appName],
|
||||
[$adt.seccount, k.managedSecretCount],
|
||||
[$adt.clientreg, $adm.yesNo(k.clientRegistered === true)]
|
||||
], probe);
|
||||
},
|
||||
|
||||
/* ── test email ──────────────────────────────────────────────────────── */
|
||||
testMailDlg: function () {
|
||||
$ocms.dlgform([
|
||||
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
|
||||
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
|
||||
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
|
||||
], {
|
||||
title: $adt.testmail,
|
||||
button: $adt.testmail_btn,
|
||||
size: [420, 560],
|
||||
addcontent: $$.dc('adm_note').text($adt.tm_hint),
|
||||
submit: function (e) {
|
||||
let c = $(this).ldng(1);
|
||||
let qs = c.serializeObject(true, { typedvalues: true });
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('admin/testmail'), data: qs, timeout: 60000, success: function (r) {
|
||||
let res = r.result || {};
|
||||
alert(res.message || (res.sent ? $adt.tm_sent : $adt.tm_failed));
|
||||
c.trigger('modal_close');
|
||||
}, error: function () {
|
||||
alert($adt.tm_failed);
|
||||
}, complete: function () { c.ldng(0); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
let $$adm = { init2: $adm.init2, auth: {} };
|
||||
export default $$adm;
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
.admfrm{padding:1.5rem}.admfrm .adm_sec{font-size:1.2rem;font-weight:700;color:#1b4379;margin:1.5rem 0 .75rem;border-bottom:2px solid #1b4379;padding-bottom:.25rem}.admfrm .adm_sec:first-child{margin-top:0}.admfrm .adm_err{padding:1rem;color:#b00020;font-weight:700}.admfrm .adm_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(22rem,1fr));gap:1rem;align-items:start}.admfrm .adm_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.admfrm .adm_card.adm_card_wide{grid-column:1/-1}.admfrm .adm_card_hd{display:flex;align-items:center;gap:.5rem;padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.admfrm .adm_card_hd>span:first-child{flex:1 1 auto}.admfrm .adm_card_bd{padding:.6rem .85rem}.admfrm .adm_card_ft{padding:.5rem .85rem .75rem;text-align:right}.admfrm table.adm_kv{border-collapse:collapse;width:100%}.admfrm table.adm_kv td{padding:.25rem .35rem;border-bottom:1px solid #eee;vertical-align:top;font-size:.92rem}.admfrm table.adm_kv td.k{color:#666;white-space:nowrap;width:40%}.admfrm table.adm_kv td.v{word-break:break-word}.admfrm table.adm_kv tr:last-child td{border-bottom:none}.admfrm .adm_probe{margin-top:.6rem;padding-top:.5rem;border-top:1px dashed #ddd;font-size:.88rem}.admfrm .adm_probe .adm_probe_msg{font-weight:600}.admfrm .adm_probe .adm_probe_det{color:#555;word-break:break-word;margin-top:.15rem}.admfrm .adm_probe .adm_probe_dur{color:#999;margin-top:.15rem;font-size:.8rem}.admfrm .adm_probe .adm_probe_metrics{margin-top:.35rem}.admfrm .adm_probe .adm_probe_metrics .adm_metric{display:flex;justify-content:space-between;gap:.5rem;padding:.1rem 0;border-bottom:1px dotted #eee}.admfrm .adm_probe .adm_probe_metrics .adm_metric:last-child{border-bottom:none}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_k{color:#555;word-break:break-word}.admfrm .adm_probe .adm_probe_metrics .adm_metric .adm_metric_v{font-weight:600;white-space:nowrap}.admfrm .adm_checks{margin-top:.5rem}.admfrm .adm_checks .adm_check{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.25rem 0;border-bottom:1px solid #eee}.admfrm .adm_checks .adm_check:last-child{border-bottom:none}.admfrm .adm_checks .adm_check .adm_check_k{color:#333}.admfrm .adm_checks .adm_check .adm_check_v{font-weight:700;font-size:.8rem;padding:.05rem .5rem;border-radius:.25rem;white-space:nowrap}.admfrm .adm_checks .adm_check .adm_check_ok{background:#e2f3da;color:#2f6d18}.admfrm .adm_checks .adm_check .adm_check_fail{background:#f3ded9;color:#c0341d}.admfrm .adm_checks .adm_check .adm_check_skipped{background:#ececec;color:#777}.admfrm .adm_pill{display:inline-block;padding:.1rem .55rem;border-radius:1rem;font-size:.78rem;font-weight:700;color:#fff;background:#999;white-space:nowrap}.admfrm .adm_pill_ok{background:#56a532}.admfrm .adm_pill_error{background:#c0341d}.admfrm .adm_pill_disabled{background:#888}.admfrm .adm_pill_unconfigured{background:#d69100}.admfrm .adm_pill_checking{background:#2a72c4}.admfrm .adm_bool{display:inline-block;padding:0 .4rem;border-radius:.25rem;font-size:.8rem;font-weight:700}.admfrm .adm_bool_y{background:#e2f3da;color:#2f6d18}.admfrm .adm_bool_n{background:#f3e0dd;color:#9c2a17}.admfrm .adm_env{font-size:.8rem;font-weight:400;padding:.1rem .5rem;border-radius:.25rem;background:rgba(255,255,255,.25)}.admfrm .adm_env.adm_env_test{background:#13a143}.admfrm .adm_btn{cursor:pointer;border-radius:.28rem;padding:.2rem .6rem;font-size:.85rem;border:1px solid #ababab;background-color:#fff;color:#333}.admfrm .adm_btn.adm_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.admfrm .adm_card_hd .adm_btn{background-color:rgba(255,255,255,.15);color:#fff;border-color:rgba(255,255,255,.4)}.adm_note{margin-top:.75rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.85rem;color:#444}
|
||||
@@ -2842,6 +2842,7 @@ $.extend($t, {
|
||||
, m_rep: 'Berichte'
|
||||
, m_todo: 'ToDos'
|
||||
, m_bcd: 'BankBuchungen'
|
||||
, m_admin: 'Administration'
|
||||
, rsp: 'Passwort ändern'
|
||||
, pnm: 'Die Passwörter stimmen nicht überein'
|
||||
, cps: 'Das neue Passwort wurde gespeichert.'
|
||||
@@ -3221,8 +3222,26 @@ $fis.draft = {
|
||||
//, { lbl: $t.m_efa, id: 'm_efa', fnc: 'init:efa' }
|
||||
]);
|
||||
})();
|
||||
|
||||
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
|
||||
The button is added — and only then does clicking it load the module script — after the
|
||||
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
|
||||
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
|
||||
$fis.addAdminMenuIfAuthorized = function () {
|
||||
if ($('#m_admin').length > 0) { return; } // idempotent
|
||||
$fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
|
||||
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
|
||||
/* Re-render the main menu cleanly: drop the previously generated menu list
|
||||
(never the .nav-right settings dropdown) and rebuild it from the array. */
|
||||
$('#mainmenu').children('ul:not(.nav-right)').remove();
|
||||
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
|
||||
}
|
||||
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
|
||||
};
|
||||
$(document).ready(function () {
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
$fis.addAdminMenuIfAuthorized();
|
||||
});
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user