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;
- }
-}