From 260dff1700775665e5d226f0cd68b58afe8ebf72 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 6 Aug 2026 23:13:04 +0200 Subject: [PATCH] Phase 2: Plattformneutralen Hosting-Kern extrahiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Predictalytics.slnx | 1 + docs/PLAN-Linux-Portierung.md | 43 +++- src/Predictalytics.Hosting/LicenseGuard.cs | 122 ++++++++++ src/Predictalytics.Hosting/LoggingSetup.cs | 159 +++++++++++++ .../Predictalytics.Hosting.csproj | 35 +++ .../PredictalyticsHost.cs} | 213 +++++++++++++----- .../PredictalyticsOptions.cs} | 100 ++++++-- .../WatchdogHeartbeatService.cs | 2 +- .../{RichTextBoxSink.cs => DelegateSink.cs} | 6 +- src/Predictalytics.WinFormsHost/MainForm.cs | 146 +++++------- .../Predictalytics.WinFormsHost.csproj | 30 +-- src/Predictalytics.WinFormsHost/Program.cs | 151 ++----------- .../Services/LicenseGate.cs | 30 +++ .../Services/LicenseGuard.cs | 102 --------- 14 files changed, 716 insertions(+), 424 deletions(-) create mode 100644 src/Predictalytics.Hosting/LicenseGuard.cs create mode 100644 src/Predictalytics.Hosting/LoggingSetup.cs create mode 100644 src/Predictalytics.Hosting/Predictalytics.Hosting.csproj rename src/{Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs => Predictalytics.Hosting/PredictalyticsHost.cs} (63%) rename src/{Predictalytics.WinFormsHost/AppSettings.cs => Predictalytics.Hosting/PredictalyticsOptions.cs} (51%) rename src/{Predictalytics.WinFormsHost/Services => Predictalytics.Hosting}/WatchdogHeartbeatService.cs (98%) rename src/Predictalytics.Infrastructure/Logging/{RichTextBoxSink.cs => DelegateSink.cs} (83%) create mode 100644 src/Predictalytics.WinFormsHost/Services/LicenseGate.cs delete mode 100644 src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs diff --git a/Predictalytics.slnx b/Predictalytics.slnx index 95e1c7c..c9d46b3 100644 --- a/Predictalytics.slnx +++ b/Predictalytics.slnx @@ -4,6 +4,7 @@ + diff --git a/docs/PLAN-Linux-Portierung.md b/docs/PLAN-Linux-Portierung.md index b64a601..0dfa4fa 100644 --- a/docs/PLAN-Linux-Portierung.md +++ b/docs/PLAN-Linux-Portierung.md @@ -213,9 +213,48 @@ Web-UI erreichbar; **Migrationen gegen die Dev-DB (`bergisnu_db0`) verifiziert, --- -## 4. Phase 2 — Hosting-Kern extrahieren (1,5–2 PT) +## 4. Phase 2 — Hosting-Kern extrahieren ✅ **erledigt (2026-08-06)** -**Der Schlüsselschritt.** Reiner Verschiebe- und Entkopplungsvorgang, keine neue Logik. +**Abnahme erreicht:** `dotnet build` → 0 Fehler. `dotnet test` → **100 bestanden, 0 Fehler, +1 übersprungen**. `Predictalytics.Hosting` baut ohne Windows-Bezug; `WinFormsHost` enthält +nur noch UI-Code (`MainForm`, `LicenseDialog`, `TerminalHelper`, `Program`) und referenziert +Api/Worker/Infrastructure/LicenseLabrador nur noch transitiv über Hosting. + +### ⚠️ Betrieblicher Hinweis: `settings.json` ging verloren + +Beim Aufräumen der Build-Ausgabe in Phase 1 wurde `bin/` gelöscht — und dort lag die zur +Laufzeit erzeugte `settings.json` mit DB-Zugangsdaten und Watchdog-API-Key. Die Datei war +nie versioniert (`.gitignore:11`) und ist nicht wiederherstellbar. + +**Beim nächsten Start neu einzutragen:** DB Server/Database/User/Password, Watchdog API Key, +ggf. Egress-Kanäle. + +Der Fehler kann sich nicht wiederholen: durch AP 2.3 liegen die Einstellungen jetzt unter +`%APPDATA%\Predictalytics\settings.json` bzw. `~/.config/Predictalytics/settings.json` — +außerhalb des Build-Verzeichnisses. `dotnet clean` oder ein Löschen von `bin/` sind damit +folgenlos. + +### Abweichungen von der Planung + +| Geplant | Tatsächlich | +|---|---| +| Hosting als `Microsoft.NET.Sdk.Web` mit `OutputType=Library` (analog Api) | **`Microsoft.NET.Sdk` + ``.** Der Web-SDK globbt `wwwroot/**` automatisch als Static Web Asset und kollidierte mit dem Content-Eintrag aus AP 2.5 (`DiscoverPrecompressedAssets`: doppelter Schlüssel). Gilt auch für den Host: WinFormsHost ist jetzt ebenfalls normaler SDK. Für die Avalonia-Shell gleich so anlegen. | +| `Process.Start("explorer.exe", …)` erst in AP 3.3 ersetzen | Vorgezogen — `MainForm` wurde ohnehin umgeschrieben. Jetzt `ProcessStartInfo { UseShellExecute = true }`, funktioniert unter Windows und Linux. | +| `LicenseGuard` komplett nach Hosting | Aufgeteilt: der plattformneutrale Teil liegt in `Hosting/LicenseGuard.cs`, der interaktive Dialogaufruf in `WinFormsHost/Services/LicenseGate.cs`. Genau die Naht, an der Phase 3 den Avalonia-Dialog bzw. den Headless-Pfad einhängt. | +| `[Editor]`-Attribut für den mehrzeiligen Egress-Editor erhalten | **Entfallen.** Es verwies auf die .NET-Framework-Assembly `System.Design`, die es in .NET (Core) nicht gibt — der Editor dürfte schon vorher nicht gegriffen haben. Die Avalonia-Settings-View bekommt ein echtes mehrzeiliges Textfeld. Alle übrigen `System.ComponentModel`-Attribute sind plattformneutral und bleiben, das PropertyGrid behält Gruppen und Beschreibungen. | + +### Zusätzlich mitgenommen + +* `PredictalyticsOptions.WebserverHost` — Bind-Adresse konfigurierbar (Vorbereitung AP 4.1), + Default `localhost`, also verhaltensgleich. +* `PredictalyticsOptions.DbSslMode` — Default bewusst `None`, damit sich das Verbindungs- + verhalten in einem reinen Refactoring nicht ändert. Umstellung auf `Preferred` ist + jetzt eine reine Konfigurationsfrage. +* `PredictalyticsHost.GetDatabaseSizeMbAsync()` — die DB-Abfrage lag vorher direkt im + `MainForm`. +* `RichTextBoxSink` → `DelegateSink` umbenannt (war nie WinForms-abhängig). + +**Reiner Verschiebe- und Entkopplungsvorgang, keine neue Fachlogik.** ### AP 2.1 — Projekt `Predictalytics.Hosting` anlegen `net10.0`, Referenzen auf `Api`, `Worker`, `Infrastructure`, `LicenseLabrador.Client`. diff --git a/src/Predictalytics.Hosting/LicenseGuard.cs b/src/Predictalytics.Hosting/LicenseGuard.cs new file mode 100644 index 0000000..14333a2 --- /dev/null +++ b/src/Predictalytics.Hosting/LicenseGuard.cs @@ -0,0 +1,122 @@ +using LicenseLabrador.Client; +using Serilog; + +namespace Predictalytics.Hosting; + +/// +/// Lizenzpruefung gegen den LicenseLabrador-Server. +/// +/// Endpunkt, Produkt-Slug und der Ed25519-Public-Key sind bewusst einkompiliert +/// (keine Benutzerkonfiguration): ein konfigurierbarer Endpunkt bzw. Key wuerde es +/// erlauben, die Anwendung auf einen gefaelschten Lizenzserver zu zeigen. +/// +/// +/// Enthaelt keinen Dialog: die interaktive Aktivierung liegt beim Host (WinForms- +/// bzw. Avalonia-Dialog), der Headless-Modus bezieht den Schluessel aus der +/// Konfiguration. +/// +/// +public static class LicenseGuard +{ + private const string ProductSlug = "predictalytics"; + private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U="; + private const string BasicAuthUser = "Labrador"; + private const string BasicAuthPassword = "Labrador02763!"; + // HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET + // macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405, + // das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense. + // Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung. + private static readonly string[] Endpoints = { "https://license.mhdf.de/public/api/v1" }; + + /// Re-check interval while the app is running (12 h). + public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12); + + public static LicenseClient CreateClient() + { + var config = new LicenseConfig + { + ProductSlug = ProductSlug, + PublicKeyBase64 = PublicKeyBase64, + Endpoints = Endpoints, + HttpBasicAuthUser = BasicAuthUser, + HttpBasicAuthPassword = BasicAuthPassword, + OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig + }; + return new LicenseClient(config); + } + + /// + /// Prueft die zwischengespeicherte Lizenz. Liefert das Ergebnis; ob es nutzbar ist, + /// beantwortet . + /// + public static Task RevalidateAsync(LicenseClient client) => client.RevalidateAsync(); + + /// Nutzbar heisst: gueltig und mit passender Pruefsumme. + public static bool IsUsable(LicenseClient client, LicenseResult? result) => + result != null && result.IsUsable && client.VerifyChecksum(result); + + /// + /// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf/Ablauf + /// waehrend des Betriebs. + /// + /// + /// Wird genau einmal aufgerufen, wenn die Lizenz endgueltig nicht mehr nutzbar ist. + /// Der Host entscheidet, was dann passiert (Meldung anzeigen, Anwendung beenden). + /// Transiente Fehler (Netz) loesen den Rueckruf nicht aus — dafuer gibt es die + /// Offline-Gnadenfrist des SDK. + /// + public static IDisposable StartPeriodicRevalidation(LicenseClient client, Action onUnusable) + { + var cts = new CancellationTokenSource(); + + _ = Task.Run(async () => + { + using var timer = new PeriodicTimer(RevalidationInterval); + try + { + while (await timer.WaitForNextTickAsync(cts.Token)) + { + try + { + var result = await client.RevalidateAsync(); + if (IsUsable(client, result)) + { + if (result.State == LicenseState.ValidOffline) + { + Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", + result.GraceUntil); + } + continue; + } + + Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", + result.State, result.Message); + onUnusable(result); + return; + } + catch (Exception ex) + { + // Transiente Fehler (Netz etc.) faengt die Offline-Gnadenfrist des SDK ab — + // eine unerwartete Ausnahme darf die Anwendung niemals beenden. + Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht)."); + } + } + } + catch (OperationCanceledException) + { + // Regulaeres Beenden. + } + }, cts.Token); + + return new Stopper(cts); + } + + private sealed class Stopper(CancellationTokenSource cts) : IDisposable + { + public void Dispose() + { + try { cts.Cancel(); } catch { /* bereits beendet */ } + cts.Dispose(); + } + } +} diff --git a/src/Predictalytics.Hosting/LoggingSetup.cs b/src/Predictalytics.Hosting/LoggingSetup.cs new file mode 100644 index 0000000..e07e52a --- /dev/null +++ b/src/Predictalytics.Hosting/LoggingSetup.cs @@ -0,0 +1,159 @@ +using Predictalytics.Infrastructure.Logging; +using Serilog; +using Serilog.Events; + +namespace Predictalytics.Hosting; + +/// +/// Serilog-Konfiguration der Anwendung. Plattformneutral: der optionale +/// Terminal-Sink bekommt lediglich eine Schreib-Action, das Marshalling auf den +/// UI-Thread liegt beim jeweiligen Host. +/// +public static class LoggingSetup +{ + private const string TextTemplate = + "[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; + + private const string SimpleTemplate = + "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; + + /// Standard-Logverzeichnis neben der Programmdatei. + public static string DefaultLogDirectory => Path.Combine(AppContext.BaseDirectory, "logs"); + + /// + /// Richtet den globalen Serilog-Logger ein. + /// + /// + /// Optionale Schreib-Action fuer eine Terminalanzeige im Host. Bekommt die + /// fertig formatierte Zeile und den Level; muss selbst auf den UI-Thread wechseln. + /// + /// Basisverzeichnis der Logdateien. Standard: . + public static void Configure( + Action? terminalSink = null, + string? logDirectory = null) + { + var logBaseDir = logDirectory ?? DefaultLogDirectory; + + var config = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning) + .Enrich.FromLogContext() + + // Suppress duplicate entry EF errors completely from logging + .Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry")) + + // ── Console (simple) ── + .WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning); + + // ── Terminalanzeige des Hosts (optional) ── + if (terminalSink != null) + { + config = config.WriteTo.Sink(new DelegateSink(terminalSink), restrictedToMinimumLevel: LogEventLevel.Warning); + } + + config = config + // ══════════════════════════════════════════════ + // FILE SINKS — By Level + // ══════════════════════════════════════════════ + + // ALL levels — complete log (daily rotation) + .WriteTo.File( + Path.Combine(logBaseDir, "all", "all-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 30, + fileSizeLimitBytes: 50_000_000, + shared: true) + + // INFO only + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Information) + .WriteTo.File( + Path.Combine(logBaseDir, "info", "info-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 14, + shared: true)) + + // WARNING only + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Warning) + .WriteTo.File( + Path.Combine(logBaseDir, "warning", "warning-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 30, + shared: true)) + + // ERROR + FATAL + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => e.Level >= LogEventLevel.Error) + .WriteTo.File( + Path.Combine(logBaseDir, "error", "error-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 60, + shared: true)) + + // ══════════════════════════════════════════════ + // FILE SINKS — By Platform + // ══════════════════════════════════════════════ + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => HasPlatform(e, "Polymarket")) + .WriteTo.File( + Path.Combine(logBaseDir, "platforms", "polymarket-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 30, + shared: true)) + + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => HasPlatform(e, "Limitless")) + .WriteTo.File( + Path.Combine(logBaseDir, "platforms", "limitless-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 30, + shared: true)) + + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => HasPlatform(e, "Azuro")) + .WriteTo.File( + Path.Combine(logBaseDir, "platforms", "azuro-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 30, + shared: true)) + + // ══════════════════════════════════════════════ + // FILE SINK — Worker / Discovery / Scoring + // ══════════════════════════════════════════════ + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(e => + e.Properties.ContainsKey("SourceContext") && + e.Properties["SourceContext"].ToString().Contains("Worker")) + .WriteTo.File( + Path.Combine(logBaseDir, "workers", "workers-.log"), + rollingInterval: RollingInterval.Day, + outputTemplate: TextTemplate, + retainedFileCountLimit: 14, + shared: true)); + + Log.Logger = config.CreateLogger(); + } + + private static bool HasPlatform(LogEvent e, string platform) => + e.Properties.ContainsKey("Platform") && + e.Properties["Platform"].ToString().Contains(platform); + + /// Startbanner, identisch fuer alle Hosts. + public static void LogStartupBanner() + { + Log.Warning("══════════════════════════════════════════════════════"); + Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!"); + Log.Warning(" 📊 First platform report in 5 minutes."); + Log.Warning("══════════════════════════════════════════════════════"); + } +} diff --git a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj new file mode 100644 index 0000000..11acea6 --- /dev/null +++ b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj @@ -0,0 +1,35 @@ + + + + Predictalytics.Hosting + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs b/src/Predictalytics.Hosting/PredictalyticsHost.cs similarity index 63% rename from src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs rename to src/Predictalytics.Hosting/PredictalyticsHost.cs index d7d58bd..cc8c4b3 100644 --- a/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs +++ b/src/Predictalytics.Hosting/PredictalyticsHost.cs @@ -1,36 +1,85 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Predictalytics.Api; -using Predictalytics.Api.Endpoints; using Predictalytics.Worker; using Predictalytics.Application.Interfaces; using Predictalytics.Domain.Interfaces; -using Predictalytics.Domain.Enums; -using Microsoft.Extensions.DependencyInjection; using Serilog; -namespace Predictalytics.WinFormsHost.Services; +namespace Predictalytics.Hosting; /// -/// Manages the lifecycle of the embedded Kestrel web server and background workers. +/// Plattformneutraler Kern der Anwendung: Lebenszyklus des eingebetteten +/// Kestrel-Webservers, der Hintergrund-Worker und der manuellen Wartungsaktionen. +/// +/// Enthaelt bewusst keinerlei UI-Bezug — die Hosts (WinForms heute, Avalonia kuenftig, +/// sowie der Headless-Modus) setzen darauf auf. +/// /// -public class EmbeddedWebServer +public sealed class PredictalyticsHost { private WebApplication? _app; private Task? _runTask; private CancellationTokenSource? _cts; private readonly object _lock = new(); - public string? ConnectionString { get; set; } - public bool DbConnectionDebug { get; set; } - public string? EgressChannelsText { get; set; } + + public PredictalyticsHost(PredictalyticsOptions options) => Options = options; + + /// Aktuelle Konfiguration. Aenderungen wirken beim naechsten Start. + public PredictalyticsOptions Options { get; set; } + + /// Wird gemeldet, wenn sich der Laufzustand aendert (fuer Statusanzeigen). + public event Action? StateChanged; + + public bool WebServerRunning { get; private set; } + public bool WorkersRunning { get; private set; } + + private void RaiseStateChanged() => StateChanged?.Invoke(); + + // ───────────────────────────────────────────────────────────────────────── + // Wartungsaktionen + // ───────────────────────────────────────────────────────────────────────── public async Task UpdateDatabaseAsync() { var services = new ServiceCollection(); - Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug); + Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( + services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), + Options.ConnectionString, Options.DbConnectionDebug); var provider = services.BuildServiceProvider(); - await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug); + await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, Options.DbConnectionDebug); + } + + /// + /// Groesse der Datenbank in MB, oder null wenn nicht ermittelbar. + /// + public async Task GetDatabaseSizeMbAsync(CancellationToken ct = default) + { + try + { + var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(Options.ConnectionString); + if (string.IsNullOrWhiteSpace(csBuilder.Database)) return null; + + using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString); + await conn.OpenAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();"; + + var result = await cmd.ExecuteScalarAsync(ct); + if (result == null || result == DBNull.Value) return null; + return Convert.ToDouble(result); + } + catch (Exception ex) + { + Log.Debug(ex, "Failed to fetch DB size"); + return null; + } } /// @@ -45,7 +94,9 @@ public class EmbeddedWebServer Log.Warning("🔄 Full trader recalculation reset requested..."); var services = new ServiceCollection(); - Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug); + Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( + services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), + Options.ConnectionString, Options.DbConnectionDebug); using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -110,7 +161,11 @@ public class EmbeddedWebServer return summary; } - public async Task StartWebServerAsync(int port = 5000) + // ───────────────────────────────────────────────────────────────────────── + // Webserver + // ───────────────────────────────────────────────────────────────────────── + + public async Task StartWebServerAsync() { lock (_lock) { if (_app != null) return; } @@ -119,32 +174,28 @@ public class EmbeddedWebServer try { var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseUrls($"http://localhost:{port}"); - - string? effectiveConnString = ConnectionString; - if (!string.IsNullOrEmpty(effectiveConnString) && (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= "))) + builder.WebHost.UseUrls(Options.WebserverUrl); + + var effectiveConnString = Options.ConnectionString; + if (!string.IsNullOrEmpty(effectiveConnString) && + (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= "))) { throw new InvalidOperationException("Connection string contains an empty 'Database' value."); } - Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(effectiveConnString ?? "NULL", "Password=[^;]+", "Password=****")); - Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug); - - if (!string.IsNullOrEmpty(EgressChannelsText)) - { - var egressOptions = ParseEgressOptions(EgressChannelsText); - builder.Services.Configure(options => - { - options.Channels = egressOptions.Channels; - }); - } + Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", + MaskPassword(effectiveConnString)); + Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( + builder.Services, builder.Configuration, effectiveConnString, Options.DbConnectionDebug); + + ApplyEgressOptions(builder.Services); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new() { Title = "Predictalytics Analytics API", Version = "v1" })); - - var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get() - ?? new[] { "http://localhost:5000" }; + + var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get() + ?? new[] { Options.WebserverUrl }; builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader())); builder.Host.UseSerilog(); @@ -163,8 +214,7 @@ public class EmbeddedWebServer // Internal dashboard: always revalidate so UI fixes reach the browser immediately. OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache" }); - app.MapGet("/", () => Results.File( - Path.Combine(wwwrootPath, "index.html"), "text/html")); + app.MapGet("/", () => Results.File(Path.Combine(wwwrootPath, "index.html"), "text/html")); } // Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints. @@ -172,17 +222,21 @@ public class EmbeddedWebServer app.MapPredictalyticsReadEndpoints(); app.MapPredictalyticsControlEndpoints(); - await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug); + await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug); lock (_lock) { _app = app; } _cts = new CancellationTokenSource(); _runTask = app.RunAsync(_cts.Token); - Log.Information("Kestrel webserver started on port {Port}", port); + WebServerRunning = true; + RaiseStateChanged(); + Log.Information("Kestrel webserver started on {Url}", Options.WebserverUrl); } catch (Exception ex) { Log.Error(ex, "Failed to start embedded web server"); + WebServerRunning = false; + RaiseStateChanged(); throw; } }); @@ -198,35 +252,50 @@ public class EmbeddedWebServer _cts?.Cancel(); try { await app.StopAsync(TimeSpan.FromSeconds(5)); } catch (OperationCanceledException) { } - await (app as IAsyncDisposable).DisposeAsync(); + await ((IAsyncDisposable)app).DisposeAsync(); Log.Information("Kestrel webserver stopped"); } + + WebServerRunning = false; + RaiseStateChanged(); } + // ───────────────────────────────────────────────────────────────────────── + // Worker + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Startet die Hintergrund-Worker und laeuft, bis ausgeloest wird. + /// public async Task StartWorkersAsync(CancellationToken ct) { Log.Information("Starting workers (no web server)..."); + WorkersRunning = true; + RaiseStateChanged(); - var host = Host.CreateDefaultBuilder() - .UseSerilog() - .ConfigureServices((ctx, services) => - { - Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****")); - Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug); - if (!string.IsNullOrEmpty(EgressChannelsText)) + try + { + var host = Host.CreateDefaultBuilder() + .UseSerilog() + .ConfigureServices((ctx, services) => { - var egressOptions = ParseEgressOptions(EgressChannelsText); - services.Configure(options => - { - options.Channels = egressOptions.Channels; - }); - } - services.AddWorkerServices(); - }) - .Build(); + Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", + MaskPassword(Options.ConnectionString)); + Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( + services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug); + ApplyEgressOptions(services); + services.AddWorkerServices(); + }) + .Build(); - await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, DbConnectionDebug); - await host.RunAsync(ct); + await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, Options.DbConnectionDebug); + await host.RunAsync(ct); + } + finally + { + WorkersRunning = false; + RaiseStateChanged(); + } } /// @@ -240,7 +309,8 @@ public class EmbeddedWebServer .UseSerilog() .ConfigureServices((ctx, services) => { - Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug); + Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics( + services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug); }) .Build(); @@ -305,17 +375,36 @@ public class EmbeddedWebServer } } + // ───────────────────────────────────────────────────────────────────────── + // Intern + // ───────────────────────────────────────────────────────────────────────── + + private static string MaskPassword(string? connectionString) => + System.Text.RegularExpressions.Regex.Replace(connectionString ?? "NULL", "Password=[^;]+", "Password=****"); + + private void ApplyEgressOptions(IServiceCollection services) + { + if (string.IsNullOrEmpty(Options.EgressChannelsText)) return; + + var egressOptions = ParseEgressOptions(Options.EgressChannelsText); + services.Configure(o => + { + o.Channels = egressOptions.Channels; + }); + } + + /// + /// Sucht das wwwroot-Verzeichnis. Im Deployment liegt es neben der Programmdatei + /// (siehe Content-Eintrag in der Host-csproj); der zweite Kandidat greift beim + /// Entwicklungslauf direkt aus bin/<config>/<tfm>/ heraus. + /// private static string? FindWwwrootPath() { var baseDir = AppContext.BaseDirectory; - // Try to find the src root by traversing up from the build output - // Typical: src/Predictalytics.WinFormsHost/bin/Debug/net8.0-windows/ var candidates = new[] { Path.Combine(baseDir, "wwwroot"), - // From bin/Debug/net8.0-windows/ up to src/, then into Api/wwwroot - Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")), - Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot")) + Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")) }; foreach (var path in candidates) @@ -332,7 +421,7 @@ public class EmbeddedWebServer return null; } - private Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text) + private static Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text) { var options = new Predictalytics.Infrastructure.Configuration.EgressOptions(); if (string.IsNullOrWhiteSpace(text)) return options; diff --git a/src/Predictalytics.WinFormsHost/AppSettings.cs b/src/Predictalytics.Hosting/PredictalyticsOptions.cs similarity index 51% rename from src/Predictalytics.WinFormsHost/AppSettings.cs rename to src/Predictalytics.Hosting/PredictalyticsOptions.cs index fe25583..0df4fde 100644 --- a/src/Predictalytics.WinFormsHost/AppSettings.cs +++ b/src/Predictalytics.Hosting/PredictalyticsOptions.cs @@ -1,32 +1,54 @@ using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Serialization; -namespace Predictalytics.WinFormsHost; +namespace Predictalytics.Hosting; -public class AppSettings +/// +/// Laufzeit-Konfiguration der Anwendung. +/// +/// Die -Attribute sind plattformneutral (Teil der +/// Basisbibliothek) und werden vom WinForms-PropertyGrid ausgewertet. Die Avalonia-Shell +/// kann sie fuer generierte Beschriftungen nutzen oder ignorieren. +/// +/// +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")] - [Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))] + [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.")] @@ -63,6 +85,8 @@ public class AppSettings [DefaultValue(60)] public int WatchdogIntervalSeconds { get; set; } = 60; + // ─── Database ─── + private string _dbServer = "localhost"; private string _dbName = ""; private string _dbUser = ""; @@ -85,10 +109,18 @@ public class AppSettings [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)] - public string ConnectionString + [JsonIgnore] + public string ConnectionString { - get + get { var builder = new MySqlConnector.MySqlConnectionStringBuilder { @@ -97,7 +129,7 @@ public class AppSettings UserID = DbUser?.Trim(), Password = DbPassword?.Trim(), AllowPublicKeyRetrieval = true, - SslMode = MySqlConnector.MySqlSslMode.None, + SslMode = DbSslMode, Pooling = true, MinimumPoolSize = 0, MaximumPoolSize = 100 @@ -106,32 +138,60 @@ public class AppSettings } } - public static AppSettings Load() + /// Kestrel-Bind-URL aus Host und Port. + [Browsable(false)] + [JsonIgnore] + public string WebserverUrl => + $"http://{(string.IsNullOrWhiteSpace(WebserverHost) ? "localhost" : WebserverHost.Trim())}:{WebserverPort}"; + + // ─── Persistenz ─── + + /// + /// Ablageort der Einstellungen: %APPDATA%\Predictalytics unter Windows, + /// ~/.config/Predictalytics unter Linux. Bewusst nicht neben der Programmdatei — + /// deren Verzeichnis ist unter Linux ueblicherweise nicht beschreibbar. + /// + public static string SettingsDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Predictalytics"); + + public static string SettingsFilePath => Path.Combine(SettingsDirectory, FileName); + + /// Alter Ablageort neben der Programmdatei (bis einschliesslich der WinForms-Fassung). + private static string LegacySettingsFilePath => Path.Combine(AppContext.BaseDirectory, FileName); + + public static PredictalyticsOptions Load() { - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName); - if (!File.Exists(filePath)) + // 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 settings = new AppSettings(); - settings.Save(); - return settings; + var fresh = new PredictalyticsOptions(); + fresh.Save(); + return fresh; } try { - var json = File.ReadAllText(filePath); - return JsonSerializer.Deserialize(json) ?? new AppSettings(); + var options = JsonSerializer.Deserialize(File.ReadAllText(path)) + ?? new PredictalyticsOptions(); + if (path == LegacySettingsFilePath) options.Save(); // Migration festschreiben + return options; } catch { - return new AppSettings(); + return new PredictalyticsOptions(); } } public void Save() { - var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName); - var options = new JsonSerializerOptions { WriteIndented = true }; - var json = JsonSerializer.Serialize(this, options); - File.WriteAllText(filePath, json); + Directory.CreateDirectory(SettingsDirectory); + File.WriteAllText(SettingsFilePath, + JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); } } diff --git a/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs b/src/Predictalytics.Hosting/WatchdogHeartbeatService.cs similarity index 98% rename from src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs rename to src/Predictalytics.Hosting/WatchdogHeartbeatService.cs index 0305f57..1bb0b7b 100644 --- a/src/Predictalytics.WinFormsHost/Services/WatchdogHeartbeatService.cs +++ b/src/Predictalytics.Hosting/WatchdogHeartbeatService.cs @@ -2,7 +2,7 @@ using System.Text; using System.Text.Json; using Serilog; -namespace Predictalytics.WinFormsHost.Services; +namespace Predictalytics.Hosting; /// /// Sends periodic dead-man's-switch heartbeats to the external Watchdog server diff --git a/src/Predictalytics.Infrastructure/Logging/RichTextBoxSink.cs b/src/Predictalytics.Infrastructure/Logging/DelegateSink.cs similarity index 83% rename from src/Predictalytics.Infrastructure/Logging/RichTextBoxSink.cs rename to src/Predictalytics.Infrastructure/Logging/DelegateSink.cs index abca50d..e4a309c 100644 --- a/src/Predictalytics.Infrastructure/Logging/RichTextBoxSink.cs +++ b/src/Predictalytics.Infrastructure/Logging/DelegateSink.cs @@ -1,4 +1,4 @@ -using Serilog.Core; +using Serilog.Core; using Serilog.Events; namespace Predictalytics.Infrastructure.Logging; @@ -7,11 +7,11 @@ namespace Predictalytics.Infrastructure.Logging; /// Custom Serilog sink that delegates log writes to a provided action. /// The action is responsible for marshaling to the correct thread (e.g. UI thread). /// -public class RichTextBoxSink : ILogEventSink +public class DelegateSink : ILogEventSink { private readonly Action _writeAction; - public RichTextBoxSink(Action writeAction) + public DelegateSink(Action writeAction) { _writeAction = writeAction; } diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs index 1730de0..fd5a22e 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.cs @@ -1,16 +1,13 @@ -using System.Reflection; -using Predictalytics.WinFormsHost.Services; +using Predictalytics.Hosting; using Serilog; namespace Predictalytics.WinFormsHost; public partial class MainForm : Form { - private EmbeddedWebServer? _webServer; + private PredictalyticsHost _host = null!; private CancellationTokenSource? _workerCts; - private bool _workerRunning; - private bool _webServerRunning; - private AppSettings _settings = null!; + private PredictalyticsOptions _settings = null!; private WatchdogHeartbeatService? _watchdog; /// Exposes the terminal RichTextBox for the Serilog sink. @@ -27,33 +24,31 @@ public partial class MainForm : Form } /// - /// Called after Serilog is configured. Initializes the embedded web server. + /// Called after Serilog is configured. Initializes the application host. /// public void Initialize() { - _settings = AppSettings.Load(); + _settings = PredictalyticsOptions.Load(); + // Der Host haelt dieselbe Options-Instanz — Aenderungen im PropertyGrid + // wirken damit ohne weitere Weitergabe beim naechsten Start. + _host = new PredictalyticsHost(_settings); + _host.StateChanged += () => BeginInvoke(UpdateStatusBar); + pg_settings.SelectedObject = _settings; - pg_settings.PropertyValueChanged += (s, e) => { + pg_settings.PropertyValueChanged += (s, e) => + { _settings.Save(); - if (_webServer != null) - { - _webServer.ConnectionString = _settings.ConnectionString; - _webServer.DbConnectionDebug = _settings.DbConnectionDebug; - _webServer.EgressChannelsText = _settings.EgressChannelsText; - } RestartWatchdog(); }; - _webServer = new EmbeddedWebServer(); - _webServer.ConnectionString = _settings.ConnectionString; - _webServer.DbConnectionDebug = _settings.DbConnectionDebug; - _webServer.EgressChannelsText = _settings.EgressChannelsText; - // Build Version (Date of compilation/file creation) - try { + try + { var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime; label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}"; - } catch { + } + catch + { label_buildVersion.Text = "Build: Unknown"; } @@ -65,7 +60,8 @@ public partial class MainForm : Form Log.Information("MainForm initialized. Ready."); Log.Information("Press 'Start Server' to begin polling & discovery."); - Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort); + Log.Information("Press 'Start Local Webserver' to launch the WebUI on {Url}", _settings.WebserverUrl); + Log.Information("Settings: {Path}", PredictalyticsOptions.SettingsFilePath); _ = UpdateDbSizeAsync(); var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 }; @@ -100,28 +96,24 @@ public partial class MainForm : Form _settings.WatchdogIntervalSeconds, metadataProvider: () => new { - workersRunning = _workerRunning, - webserverRunning = _webServerRunning + workersRunning = _host.WorkersRunning, + webserverRunning = _host.WebServerRunning }); _watchdog.Start(); } private async void Btn_serverstart_Click(object? sender, EventArgs e) { - if (!_workerRunning) + if (!_host.WorkersRunning) { // Start workers _workerCts = new CancellationTokenSource(); - _workerRunning = true; btn_serverstart.Text = "⏹ Stop Server"; Log.Information("🚀 Starting background workers..."); - + try { - _webServer!.ConnectionString = _settings.ConnectionString; - _webServer!.DbConnectionDebug = _settings.DbConnectionDebug; - _webServer!.EgressChannelsText = _settings.EgressChannelsText; - await _webServer!.StartWorkersAsync(_workerCts.Token); + await _host.StartWorkersAsync(_workerCts.Token); } catch (OperationCanceledException) { } catch (Exception ex) { Log.Error(ex, "Worker error"); } @@ -131,7 +123,6 @@ public partial class MainForm : Form // Stop workers Log.Information("⏹ Stopping background workers..."); _workerCts?.Cancel(); - _workerRunning = false; btn_serverstart.Text = "▶ Start Server"; Log.Information("Workers stopped."); } @@ -140,28 +131,25 @@ public partial class MainForm : Form private async void Btn_localWebserver_Click(object? sender, EventArgs e) { - if (!_webServerRunning) + if (!_host.WebServerRunning) { try { - Log.Information("🌐 Starting embedded Kestrel webserver on http://localhost:{Port}...", _settings.WebserverPort); - await _webServer!.StartWebServerAsync(_settings.WebserverPort); - _webServerRunning = true; + Log.Information("🌐 Starting embedded Kestrel webserver on {Url}...", _settings.WebserverUrl); + await _host.StartWebServerAsync(); btn_localWebserver.Text = "⏹ Stop Webserver"; - Log.Information("✅ WebUI available at http://localhost:{Port}", _settings.WebserverPort); - Log.Information("📄 Swagger API docs at http://localhost:{Port}/swagger", _settings.WebserverPort); + Log.Information("✅ WebUI available at {Url}", _settings.WebserverUrl); + Log.Information("📄 Swagger API docs at {Url}/swagger", _settings.WebserverUrl); } catch (Exception ex) { Log.Error(ex, "Failed to start webserver"); - _webServerRunning = false; } } else { Log.Information("⏹ Stopping webserver..."); - await _webServer!.StopWebServerAsync(); - _webServerRunning = false; + await _host.StopWebServerAsync(); btn_localWebserver.Text = "▶ Start Webserver"; Log.Information("Webserver stopped."); } @@ -170,8 +158,10 @@ public partial class MainForm : Form private void UpdateStatusBar() { - var workerStatus = _workerRunning ? "[RUNNING] Workers" : "[STOPPED] Workers"; - var serverStatus = _webServerRunning ? $"[RUNNING] Webserver :{_settings.WebserverPort}" : "[STOPPED] Webserver"; + var workerStatus = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers"; + var serverStatus = _host.WebServerRunning + ? $"[RUNNING] Webserver :{_settings.WebserverPort}" + : "[STOPPED] Webserver"; this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}"; } @@ -181,7 +171,7 @@ public partial class MainForm : Form _watchdog?.Dispose(); _watchdog = null; _workerCts?.Cancel(); - _webServer?.StopWebServerAsync().GetAwaiter().GetResult(); + _host?.StopWebServerAsync().GetAwaiter().GetResult(); base.OnFormClosing(e); } @@ -189,11 +179,11 @@ public partial class MainForm : Form { try { - System.Diagnostics.Process.Start("explorer.exe", $"\"http://localhost:{_settings.WebserverPort}\""); + OpenInShell(_settings.WebserverUrl); } catch (Exception ex) { - Serilog.Log.Error(ex, "Fehler beim Öffnen des Browsers"); + Log.Error(ex, "Fehler beim Öffnen des Browsers"); MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); } } @@ -202,21 +192,26 @@ public partial class MainForm : Form { try { - var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs"); - if (Directory.Exists(logPath)) - System.Diagnostics.Process.Start("explorer.exe", logPath); - else - System.Diagnostics.Process.Start("explorer.exe", Environment.CurrentDirectory); + var logPath = LoggingSetup.DefaultLogDirectory; + OpenInShell(Directory.Exists(logPath) ? logPath : Environment.CurrentDirectory); } catch (Exception ex) { - Serilog.Log.Error(ex, "Fehler beim Öffnen des Log-Ordners"); + Log.Error(ex, "Fehler beim Öffnen des Log-Ordners"); } } + /// + /// Oeffnet Pfad oder URL mit der Standardanwendung. UseShellExecute funktioniert + /// unter Windows wie unter Linux (dort ueber xdg-open) — im Gegensatz zum + /// vorherigen direkten Aufruf von explorer.exe. + /// + private static void OpenInShell(string target) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true }); + private async void syncMarketsaToolStripMenuItem_Click(object? sender, EventArgs e) { - if (_workerRunning) + if (_host.WorkersRunning) { MessageBox.Show("Market sync cannot be started while background workers are running.", "Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning); @@ -227,11 +222,11 @@ public partial class MainForm : Form { btn_syncmarkets.Enabled = false; Log.Information("Manual market sync triggered..."); - + // Use a temporary CTS for this operation using var cts = new CancellationTokenSource(); - await _webServer!.RunSingleMarketSyncAsync(cts.Token); - + await _host.RunSingleMarketSyncAsync(cts.Token); + Log.Information("Manual market sync completed successfully."); MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); } @@ -248,7 +243,7 @@ public partial class MainForm : Form private async void btn_dbUpdate_Click(object? sender, EventArgs e) { - if (_workerRunning) + if (_host.WorkersRunning) { MessageBox.Show("Database update cannot be run while background workers are running.", "Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning); @@ -259,7 +254,7 @@ public partial class MainForm : Form { btn_dbUpdate.Enabled = false; Log.Information("Manual database update triggered..."); - await _webServer!.UpdateDatabaseAsync(); + await _host.UpdateDatabaseAsync(); Log.Information("Database updated successfully."); MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); } @@ -276,7 +271,7 @@ public partial class MainForm : Form private async void btn_recalcAll_Click(object? sender, EventArgs e) { - if (_workerRunning) + if (_host.WorkersRunning) { MessageBox.Show("Recalculation cannot be started while background workers are running. Stop the server first.", "Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning); @@ -298,7 +293,7 @@ public partial class MainForm : Form Log.Information("Manual full recalculation reset triggered..."); using var cts = new CancellationTokenSource(); - var summary = await _webServer!.RunRecalculateAllTradersAsync(cts.Token); + var summary = await _host.RunRecalculateAllTradersAsync(cts.Token); MessageBox.Show($"Reset complete:\n\n{summary}\n\nNow start the server to rebuild the analytics.", "Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information); @@ -316,29 +311,10 @@ public partial class MainForm : Form private async Task UpdateDbSizeAsync() { - try - { - // Build the connection string - var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(_settings.ConnectionString); - if (string.IsNullOrWhiteSpace(csBuilder.Database)) - return; // Not ready or valid yet - - using var conn = new MySqlConnector.MySqlConnection(_settings.ConnectionString); - await conn.OpenAsync(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();"; - - var result = await cmd.ExecuteScalarAsync(); - if (result != DBNull.Value && result != null) - { - var sizeMb = Convert.ToDouble(result); - this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB"); - } - } - catch (Exception ex) - { - this.Invoke(() => label_dbSize.Text = "DB Size: Error"); - Log.Debug(ex, "Failed to fetch DB size for status bar"); - } + var sizeMb = await _host.GetDatabaseSizeMbAsync(); + if (IsDisposed) return; + this.Invoke(() => label_dbSize.Text = sizeMb.HasValue + ? $"DB Size: {sizeMb.Value:F2} MB" + : "DB Size: —"); } } diff --git a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj index 0b24b7a..2c64be8 100644 --- a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj +++ b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -13,27 +13,27 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - - - - - - - + + + + + + + diff --git a/src/Predictalytics.WinFormsHost/Program.cs b/src/Predictalytics.WinFormsHost/Program.cs index b561731..e8711de 100644 --- a/src/Predictalytics.WinFormsHost/Program.cs +++ b/src/Predictalytics.WinFormsHost/Program.cs @@ -1,7 +1,6 @@ -using Predictalytics.Infrastructure.Logging; +using Predictalytics.Hosting; using Predictalytics.WinFormsHost.Services; using Serilog; -using Serilog.Events; namespace Predictalytics.WinFormsHost; @@ -13,149 +12,33 @@ internal static class Program ApplicationConfiguration.Initialize(); // ─── License gate: no usable license, no app ─── - var licenseClient = LicenseGuard.EnsureLicensed(); + var licenseClient = LicenseGate.EnsureLicensed(); if (licenseClient == null) { return; } var mainForm = new MainForm(); - var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm); - // ─── Output template matching terminal format ─── - const string textTemplate = - "[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; - - const string simpleTemplate = - "[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}"; - - // ─── Log directory ─── - var logBaseDir = Path.Combine(AppContext.BaseDirectory, "logs"); - - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() - .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning) - .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) - .MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning) - .Enrich.FromLogContext() - - // Suppress duplicate entry EF errors completely from logging - .Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry")) - - // ── Console (simple) ── - .WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning) - - // ── RichTextBox Terminal ── - .WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning) - - // ══════════════════════════════════════════════ - // FILE SINKS — By Level - // ══════════════════════════════════════════════ - - // ALL levels — complete log (daily rotation) - .WriteTo.File( - Path.Combine(logBaseDir, "all", "all-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 30, - fileSizeLimitBytes: 50_000_000, - shared: true) - - // INFO only - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Information) - .WriteTo.File( - Path.Combine(logBaseDir, "info", "info-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 14, - shared: true)) - - // WARNING only - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Warning) - .WriteTo.File( - Path.Combine(logBaseDir, "warning", "warning-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 30, - shared: true)) - - // ERROR + FATAL - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => e.Level >= LogEventLevel.Error) - .WriteTo.File( - Path.Combine(logBaseDir, "error", "error-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 60, - shared: true)) - - // ══════════════════════════════════════════════ - // FILE SINKS — By Platform - // ══════════════════════════════════════════════ - - // Polymarket - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => - e.Properties.ContainsKey("Platform") && - e.Properties["Platform"].ToString().Contains("Polymarket")) - .WriteTo.File( - Path.Combine(logBaseDir, "platforms", "polymarket-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 30, - shared: true)) - - // Limitless - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => - e.Properties.ContainsKey("Platform") && - e.Properties["Platform"].ToString().Contains("Limitless")) - .WriteTo.File( - Path.Combine(logBaseDir, "platforms", "limitless-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 30, - shared: true)) - - // Azuro - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => - e.Properties.ContainsKey("Platform") && - e.Properties["Platform"].ToString().Contains("Azuro")) - .WriteTo.File( - Path.Combine(logBaseDir, "platforms", "azuro-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 30, - shared: true)) - - // ══════════════════════════════════════════════ - // FILE SINK — Worker / Discovery / Scoring - // ══════════════════════════════════════════════ - .WriteTo.Logger(lc => lc - .Filter.ByIncludingOnly(e => - e.Properties.ContainsKey("SourceContext") && - e.Properties["SourceContext"].ToString().Contains("Worker")) - .WriteTo.File( - Path.Combine(logBaseDir, "workers", "workers-.log"), - rollingInterval: RollingInterval.Day, - outputTemplate: textTemplate, - retainedFileCountLimit: 14, - shared: true)) - - .CreateLogger(); - - Log.Warning("══════════════════════════════════════════════════════"); - Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!"); - Log.Warning(" 📊 First platform report in 5 minutes."); - Log.Warning("══════════════════════════════════════════════════════"); + // Serilog-Aufbau liegt in Predictalytics.Hosting; hier wird lediglich der + // Terminal-Sink beigesteuert, der auf den UI-Thread marshallt. + LoggingSetup.Configure(TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm)); + LoggingSetup.LogStartupBanner(); mainForm.Initialize(); // While running: re-check the license every 12 h (revocation/expiry/offline grace). - using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient); + using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(licenseClient, result => + { + if (mainForm.IsDisposed) return; + mainForm.BeginInvoke(() => + { + MessageBox.Show( + $"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.", + "Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop); + System.Windows.Forms.Application.Exit(); + }); + }); System.Windows.Forms.Application.Run(mainForm); diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseGate.cs b/src/Predictalytics.WinFormsHost/Services/LicenseGate.cs new file mode 100644 index 0000000..141bc1c --- /dev/null +++ b/src/Predictalytics.WinFormsHost/Services/LicenseGate.cs @@ -0,0 +1,30 @@ +using LicenseLabrador.Client; +using Predictalytics.Hosting; + +namespace Predictalytics.WinFormsHost.Services; + +/// +/// WinForms-seitige Lizenzschranke: verbindet den plattformneutralen +/// mit dem interaktiven Aktivierungsdialog. +/// +internal static class LicenseGate +{ + /// + /// Blockiert, bis eine nutzbare Lizenz vorliegt. Zuerst wird der zwischengespeicherte + /// Schluessel geprueft; erst wenn der nicht (mehr) nutzbar ist, erscheint der Dialog. + /// Liefert null, wenn der Benutzer abbricht — die Anwendung muss dann beendet werden. + /// + public static LicenseClient? EnsureLicensed() + { + var client = LicenseGuard.CreateClient(); + + var result = LicenseGuard.RevalidateAsync(client).GetAwaiter().GetResult(); + if (LicenseGuard.IsUsable(client, result)) + { + return client; + } + + using var dialog = new LicenseDialog(client, result); + return dialog.ShowDialog() == DialogResult.OK ? client : null; + } +} diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs b/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs deleted file mode 100644 index d463c34..0000000 --- a/src/Predictalytics.WinFormsHost/Services/LicenseGuard.cs +++ /dev/null @@ -1,102 +0,0 @@ -using LicenseLabrador.Client; -using Serilog; - -namespace Predictalytics.WinFormsHost.Services; - -/// -/// Startup license gate backed by the LicenseLabrador server. -/// Endpoint, product slug and the Ed25519 public key are deliberately compiled in -/// (not user configuration): a configurable endpoint/key would let anyone point the -/// app at a fake license server. -/// -public static class LicenseGuard -{ - private const string ProductSlug = "predictalytics"; - private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U="; - private const string BasicAuthUser = "Labrador"; - private const string BasicAuthPassword = "Labrador02763!"; - // HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET - // macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405, - // das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense. - // Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung. - private static readonly string[] Endpoints = { "https://license.mhdf.de/public/api/v1" }; - - /// Re-check interval while the app is running (12 h). - public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000; - - public static LicenseClient CreateClient() - { - var config = new LicenseConfig - { - ProductSlug = ProductSlug, - PublicKeyBase64 = PublicKeyBase64, - Endpoints = Endpoints, - HttpBasicAuthUser = BasicAuthUser, - HttpBasicAuthPassword = BasicAuthPassword, - OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig - }; - return new LicenseClient(config); - } - - /// - /// Blocks until a usable license is present. Tries the cached key first; otherwise - /// (or when the cached key is no longer usable) shows the license dialog. - /// Returns null if the user gave up — the app must exit then. - /// - public static LicenseClient? EnsureLicensed() - { - var client = CreateClient(); - - var result = client.RevalidateAsync().GetAwaiter().GetResult(); - if (result.IsUsable && client.VerifyChecksum(result)) - { - return client; - } - - using var dialog = new LicenseDialog(client, result); - if (dialog.ShowDialog() != DialogResult.OK) - { - return null; - } - return client; - } - - /// - /// Starts the periodic in-app revalidation. Detects revocation/expiry while the app - /// keeps running; on a definitively unusable license the app is shut down. - /// - public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client) - { - var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs }; - timer.Tick += async (_, _) => - { - try - { - var result = await client.RevalidateAsync(); - if (result.IsUsable && client.VerifyChecksum(result)) - { - if (result.State == LicenseState.ValidOffline) - { - Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil); - } - return; - } - - timer.Stop(); - Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message); - MessageBox.Show( - $"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.", - "Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop); - System.Windows.Forms.Application.Exit(); - } - catch (Exception ex) - { - // Transient errors (network etc.) are handled by the SDK's offline grace — - // never kill the app from an unexpected exception here. - Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht)."); - } - }; - timer.Start(); - return timer; - } -}