Phase 2: Plattformneutralen Hosting-Kern extrahiert

Neues Projekt Predictalytics.Hosting nimmt auf, was bisher im
windows-gebundenen WinFormsHost feststeckte, aber portabel ist:

- PredictalyticsHost (aus EmbeddedWebServer): Kestrel- und Worker-Lifecycle,
  Wartungsaktionen, DB-Groesse. Meldet Zustandswechsel ueber StateChanged.
- PredictalyticsOptions (aus AppSettings): ohne WinForms-Bezug. Die
  System.ComponentModel-Attribute sind plattformneutral und bleiben, damit
  das PropertyGrid Gruppen und Beschreibungen behaelt.
- LoggingSetup (aus Program.cs): Serilog-Aufbau, Terminal-Sink als optionale
  Action statt fester RichTextBox.
- LicenseGuard: GUI-frei. Periodische Revalidierung ueber PeriodicTimer statt
  WinForms-Timer, Abbruch ueber Callback statt Application.Exit. Der
  interaktive Dialogaufruf bleibt als LicenseGate im WinForms-Host.
- WatchdogHeartbeatService unveraendert verschoben.

Infrastructure: RichTextBoxSink -> DelegateSink umbenannt (war nie
WinForms-abhaengig, nur missverstaendlich benannt).

Einstellungen liegen jetzt unter %APPDATA%/Predictalytics bzw.
~/.config/Predictalytics statt neben der Programmdatei, mit einmaliger
Uebernahme aus dem alten Ort. Das Installationsverzeichnis ist unter Linux
ueblicherweise nicht beschreibbar.

wwwroot wird ueber einen Content-Eintrag neben die Programmdatei kopiert;
die frueheren Pfad-Heuristiken entfallen.

Hosting und WinFormsHost nutzen Microsoft.NET.Sdk statt Sdk.Web: der Web-SDK
globbt wwwroot automatisch als Static Web Asset und kollidiert mit dem
Content-Eintrag. WebApplication kommt ueber FrameworkReference.

Neu konfigurierbar (verhaltensgleiche Defaults): WebserverHost fuer die
Kestrel-Bind-Adresse, DbSslMode fuer die MySQL-Verschluesselung.

explorer.exe-Aufrufe durch ProcessStartInfo mit UseShellExecute ersetzt —
funktioniert unter Windows und Linux.

Build: 0 Fehler. Tests: 100 bestanden, 0 Fehler, 1 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 23:13:04 +02:00
co-authored by Claude Opus 5
parent c9eff9f75e
commit 260dff1700
14 changed files with 716 additions and 424 deletions
@@ -0,0 +1,197 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.Hosting;
/// <summary>
/// Laufzeit-Konfiguration der Anwendung.
/// <para>
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
/// Basisbibliothek) und werden vom WinForms-PropertyGrid ausgewertet. Die Avalonia-Shell
/// kann sie fuer generierte Beschriftungen nutzen oder ignorieren.
/// </para>
/// </summary>
public sealed class PredictalyticsOptions
{
private const string FileName = "settings.json";
// ─── Webserver ───
[Category("Webserver")]
[DisplayName("Port")]
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
[DefaultValue(5000)]
public int WebserverPort { get; set; } = 5000;
[Category("Webserver")]
[DisplayName("Bind-Adresse")]
[Description("Netzwerkschnittstelle, auf der Kestrel lauscht. 'localhost' = nur lokal (Standard), " +
"'0.0.0.0' = von außen erreichbar. Bei 0.0.0.0 muss die API abgesichert werden!")]
[DefaultValue("localhost")]
public string WebserverHost { get; set; } = "localhost";
[Category("Webserver")]
[DisplayName("Database Debug")]
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
[DefaultValue(false)]
public bool DbConnectionDebug { get; set; } = false;
// ─── Egress ───
private string _egressChannelsText = "";
[Category("Egress (Proxy/IP)")]
[DisplayName("Egress Channels")]
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). " +
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
// ─── Watchdog ───
[Category("Watchdog")]
[DisplayName("Enabled")]
[Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")]
[DefaultValue(true)]
public bool WatchdogEnabled { get; set; } = true;
[Category("Watchdog")]
[DisplayName("Server URL")]
[Description("Basis-URL des Watchdog-Servers.")]
[DefaultValue("https://watchdog.mhdf.de")]
public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de";
[Category("Watchdog")]
[DisplayName("API Key")]
[Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")]
[PasswordPropertyText(true)]
public string WatchdogApiKey { get; set; } = "";
[Category("Watchdog")]
[DisplayName("Source")]
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
[DefaultValue("Predictalytics")]
public string WatchdogSource { get; set; } = "Predictalytics";
[Category("Watchdog")]
[DisplayName("Instance")]
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
[DefaultValue("default")]
public string WatchdogInstance { get; set; } = "default";
[Category("Watchdog")]
[DisplayName("Interval (Sekunden)")]
[Description("Sende-Takt der Heartbeats. Der Watchdog alarmiert, wenn ~1,5× dieses Intervall + 30 s ohne Heartbeat vergehen.")]
[DefaultValue(60)]
public int WatchdogIntervalSeconds { get; set; } = 60;
// ─── Database ───
private string _dbServer = "localhost";
private string _dbName = "";
private string _dbUser = "";
private string _dbPassword = "";
[Category("Database")]
[DisplayName("Server")]
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
[Category("Database")]
[DisplayName("Database")]
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
[Category("Database")]
[DisplayName("User")]
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
[Category("Database")]
[DisplayName("Password")]
[PasswordPropertyText(true)]
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
[Category("Database")]
[DisplayName("SSL Mode")]
[Description("Verschlüsselung der MySQL-Verbindung. 'None' = unverschlüsselt (bisheriges Verhalten), " +
"'Preferred' = verschlüsselt wenn der Server es unterstützt. Bei entfernten Servern 'Preferred' empfohlen.")]
[DefaultValue(MySqlConnector.MySqlSslMode.None)]
public MySqlConnector.MySqlSslMode DbSslMode { get; set; } = MySqlConnector.MySqlSslMode.None;
[Browsable(false)]
[JsonIgnore]
public string ConnectionString
{
get
{
var builder = new MySqlConnector.MySqlConnectionStringBuilder
{
Server = DbServer?.Trim(),
Database = DbName?.Trim(),
UserID = DbUser?.Trim(),
Password = DbPassword?.Trim(),
AllowPublicKeyRetrieval = true,
SslMode = DbSslMode,
Pooling = true,
MinimumPoolSize = 0,
MaximumPoolSize = 100
};
return builder.ConnectionString;
}
}
/// <summary>Kestrel-Bind-URL aus Host und Port.</summary>
[Browsable(false)]
[JsonIgnore]
public string WebserverUrl =>
$"http://{(string.IsNullOrWhiteSpace(WebserverHost) ? "localhost" : WebserverHost.Trim())}:{WebserverPort}";
// ─── Persistenz ───
/// <summary>
/// Ablageort der Einstellungen: %APPDATA%\Predictalytics unter Windows,
/// ~/.config/Predictalytics unter Linux. Bewusst nicht neben der Programmdatei —
/// deren Verzeichnis ist unter Linux ueblicherweise nicht beschreibbar.
/// </summary>
public static string SettingsDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Predictalytics");
public static string SettingsFilePath => Path.Combine(SettingsDirectory, FileName);
/// <summary>Alter Ablageort neben der Programmdatei (bis einschliesslich der WinForms-Fassung).</summary>
private static string LegacySettingsFilePath => Path.Combine(AppContext.BaseDirectory, FileName);
public static PredictalyticsOptions Load()
{
// Neuer Ort hat Vorrang; sonst einmalig aus dem alten uebernehmen, damit
// bestehende Zugangsdaten beim Umstieg nicht verloren gehen.
var path = File.Exists(SettingsFilePath) ? SettingsFilePath
: File.Exists(LegacySettingsFilePath) ? LegacySettingsFilePath
: null;
if (path == null)
{
var fresh = new PredictalyticsOptions();
fresh.Save();
return fresh;
}
try
{
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
?? new PredictalyticsOptions();
if (path == LegacySettingsFilePath) options.Save(); // Migration festschreiben
return options;
}
catch
{
return new PredictalyticsOptions();
}
}
public void Save()
{
Directory.CreateDirectory(SettingsDirectory);
File.WriteAllText(SettingsFilePath,
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
}