Phase 2: Plattformneutralen Hosting-Kern extrahiert
Neues Projekt Predictalytics.Hosting nimmt auf, was bisher im windows-gebundenen WinFormsHost feststeckte, aber portabel ist: - PredictalyticsHost (aus EmbeddedWebServer): Kestrel- und Worker-Lifecycle, Wartungsaktionen, DB-Groesse. Meldet Zustandswechsel ueber StateChanged. - PredictalyticsOptions (aus AppSettings): ohne WinForms-Bezug. Die System.ComponentModel-Attribute sind plattformneutral und bleiben, damit das PropertyGrid Gruppen und Beschreibungen behaelt. - LoggingSetup (aus Program.cs): Serilog-Aufbau, Terminal-Sink als optionale Action statt fester RichTextBox. - LicenseGuard: GUI-frei. Periodische Revalidierung ueber PeriodicTimer statt WinForms-Timer, Abbruch ueber Callback statt Application.Exit. Der interaktive Dialogaufruf bleibt als LicenseGate im WinForms-Host. - WatchdogHeartbeatService unveraendert verschoben. Infrastructure: RichTextBoxSink -> DelegateSink umbenannt (war nie WinForms-abhaengig, nur missverstaendlich benannt). Einstellungen liegen jetzt unter %APPDATA%/Predictalytics bzw. ~/.config/Predictalytics statt neben der Programmdatei, mit einmaliger Uebernahme aus dem alten Ort. Das Installationsverzeichnis ist unter Linux ueblicherweise nicht beschreibbar. wwwroot wird ueber einen Content-Eintrag neben die Programmdatei kopiert; die frueheren Pfad-Heuristiken entfallen. Hosting und WinFormsHost nutzen Microsoft.NET.Sdk statt Sdk.Web: der Web-SDK globbt wwwroot automatisch als Static Web Asset und kollidiert mit dem Content-Eintrag. WebApplication kommt ueber FrameworkReference. Neu konfigurierbar (verhaltensgleiche Defaults): WebserverHost fuer die Kestrel-Bind-Adresse, DbSslMode fuer die MySQL-Verschluesselung. explorer.exe-Aufrufe durch ProcessStartInfo mit UseShellExecute ersetzt — funktioniert unter Windows und Linux. Build: 0 Fehler. Tests: 100 bestanden, 0 Fehler, 1 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
<Project Path="src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj" />
|
<Project Path="src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj" />
|
||||||
<Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" />
|
<Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" />
|
||||||
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
||||||
|
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
|
||||||
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
||||||
<Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
|
<Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
|
||||||
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
||||||
|
|||||||
@@ -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` + `<FrameworkReference Include="Microsoft.AspNetCore.App" />`.** 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
|
### AP 2.1 — Projekt `Predictalytics.Hosting` anlegen
|
||||||
`net10.0`, Referenzen auf `Api`, `Worker`, `Infrastructure`, `LicenseLabrador.Client`.
|
`net10.0`, Referenzen auf `Api`, `Worker`, `Infrastructure`, `LicenseLabrador.Client`.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using LicenseLabrador.Client;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lizenzpruefung gegen den LicenseLabrador-Server.
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Enthaelt keinen Dialog: die interaktive Aktivierung liegt beim Host (WinForms-
|
||||||
|
/// bzw. Avalonia-Dialog), der Headless-Modus bezieht den Schluessel aus der
|
||||||
|
/// Konfiguration.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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" };
|
||||||
|
|
||||||
|
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prueft die zwischengespeicherte Lizenz. Liefert das Ergebnis; ob es nutzbar ist,
|
||||||
|
/// beantwortet <see cref="IsUsable"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static Task<LicenseResult> RevalidateAsync(LicenseClient client) => client.RevalidateAsync();
|
||||||
|
|
||||||
|
/// <summary>Nutzbar heisst: gueltig <em>und</em> mit passender Pruefsumme.</summary>
|
||||||
|
public static bool IsUsable(LicenseClient client, LicenseResult? result) =>
|
||||||
|
result != null && result.IsUsable && client.VerifyChecksum(result);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf/Ablauf
|
||||||
|
/// waehrend des Betriebs.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="onUnusable">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
public static IDisposable StartPeriodicRevalidation(LicenseClient client, Action<LicenseResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serilog-Konfiguration der Anwendung. Plattformneutral: der optionale
|
||||||
|
/// Terminal-Sink bekommt lediglich eine Schreib-Action, das Marshalling auf den
|
||||||
|
/// UI-Thread liegt beim jeweiligen Host.
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
|
||||||
|
/// <summary>Standard-Logverzeichnis neben der Programmdatei.</summary>
|
||||||
|
public static string DefaultLogDirectory => Path.Combine(AppContext.BaseDirectory, "logs");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Richtet den globalen Serilog-Logger ein.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="terminalSink">
|
||||||
|
/// Optionale Schreib-Action fuer eine Terminalanzeige im Host. Bekommt die
|
||||||
|
/// fertig formatierte Zeile und den Level; muss selbst auf den UI-Thread wechseln.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="logDirectory">Basisverzeichnis der Logdateien. Standard: <see cref="DefaultLogDirectory"/>.</param>
|
||||||
|
public static void Configure(
|
||||||
|
Action<string, LogEventLevel>? 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);
|
||||||
|
|
||||||
|
/// <summary>Startbanner, identisch fuer alle Hosts.</summary>
|
||||||
|
public static void LogStartupBanner()
|
||||||
|
{
|
||||||
|
Log.Warning("══════════════════════════════════════════════════════");
|
||||||
|
Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!");
|
||||||
|
Log.Warning(" 📊 First platform report in 5 minutes.");
|
||||||
|
Log.Warning("══════════════════════════════════════════════════════");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Predictalytics.Hosting</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Bewusst der normale SDK statt Microsoft.NET.Sdk.Web: das hier ist eine
|
||||||
|
Bibliothek, kein Web-Projekt. Der FrameworkReference liefert WebApplication
|
||||||
|
und Kestrel, ohne die Static-Web-Assets-Maschinerie des Web-SDK
|
||||||
|
mitzuschleppen (die mit dem wwwroot-Content der Hosts kollidiert). -->
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
|
<PackageReference Include="Serilog.Formatting.Compact" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
|
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
|
||||||
|
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+140
-51
@@ -1,36 +1,85 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
using Predictalytics.Api;
|
using Predictalytics.Api;
|
||||||
using Predictalytics.Api.Endpoints;
|
|
||||||
using Predictalytics.Worker;
|
using Predictalytics.Worker;
|
||||||
using Predictalytics.Application.Interfaces;
|
using Predictalytics.Application.Interfaces;
|
||||||
using Predictalytics.Domain.Interfaces;
|
using Predictalytics.Domain.Interfaces;
|
||||||
using Predictalytics.Domain.Enums;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost.Services;
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
|
/// <para>
|
||||||
|
/// Enthaelt bewusst keinerlei UI-Bezug — die Hosts (WinForms heute, Avalonia kuenftig,
|
||||||
|
/// sowie der Headless-Modus) setzen darauf auf.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EmbeddedWebServer
|
public sealed class PredictalyticsHost
|
||||||
{
|
{
|
||||||
private WebApplication? _app;
|
private WebApplication? _app;
|
||||||
private Task? _runTask;
|
private Task? _runTask;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
public string? ConnectionString { get; set; }
|
|
||||||
public bool DbConnectionDebug { get; set; }
|
public PredictalyticsHost(PredictalyticsOptions options) => Options = options;
|
||||||
public string? EgressChannelsText { get; set; }
|
|
||||||
|
/// <summary>Aktuelle Konfiguration. Aenderungen wirken beim naechsten Start.</summary>
|
||||||
|
public PredictalyticsOptions Options { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wird gemeldet, wenn sich der Laufzustand aendert (fuer Statusanzeigen).</summary>
|
||||||
|
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()
|
public async Task UpdateDatabaseAsync()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
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();
|
var provider = services.BuildServiceProvider();
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, Options.DbConnectionDebug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groesse der Datenbank in MB, oder null wenn nicht ermittelbar.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<double?> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,7 +94,9 @@ public class EmbeddedWebServer
|
|||||||
Log.Warning("🔄 Full trader recalculation reset requested...");
|
Log.Warning("🔄 Full trader recalculation reset requested...");
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
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 provider = services.BuildServiceProvider();
|
||||||
using var scope = provider.CreateScope();
|
using var scope = provider.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
|
||||||
@@ -110,7 +161,11 @@ public class EmbeddedWebServer
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartWebServerAsync(int port = 5000)
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webserver
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task StartWebServerAsync()
|
||||||
{
|
{
|
||||||
lock (_lock) { if (_app != null) return; }
|
lock (_lock) { if (_app != null) return; }
|
||||||
|
|
||||||
@@ -119,32 +174,28 @@ public class EmbeddedWebServer
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder();
|
var builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseUrls($"http://localhost:{port}");
|
builder.WebHost.UseUrls(Options.WebserverUrl);
|
||||||
|
|
||||||
string? effectiveConnString = ConnectionString;
|
var effectiveConnString = Options.ConnectionString;
|
||||||
if (!string.IsNullOrEmpty(effectiveConnString) && (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
|
if (!string.IsNullOrEmpty(effectiveConnString) &&
|
||||||
|
(effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Connection string contains an empty 'Database' value.");
|
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=****"));
|
Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}",
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug);
|
MaskPassword(effectiveConnString));
|
||||||
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
||||||
|
builder.Services, builder.Configuration, effectiveConnString, Options.DbConnectionDebug);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(EgressChannelsText))
|
ApplyEgressOptions(builder.Services);
|
||||||
{
|
|
||||||
var egressOptions = ParseEgressOptions(EgressChannelsText);
|
|
||||||
builder.Services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Channels = egressOptions.Channels;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
||||||
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
||||||
|
|
||||||
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
|
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
|
||||||
?? new[] { "http://localhost:5000" };
|
?? new[] { Options.WebserverUrl };
|
||||||
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
||||||
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
@@ -163,8 +214,7 @@ public class EmbeddedWebServer
|
|||||||
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
|
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
|
||||||
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
|
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
|
||||||
});
|
});
|
||||||
app.MapGet("/", () => Results.File(
|
app.MapGet("/", () => Results.File(Path.Combine(wwwrootPath, "index.html"), "text/html"));
|
||||||
Path.Combine(wwwrootPath, "index.html"), "text/html"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
|
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
|
||||||
@@ -172,17 +222,21 @@ public class EmbeddedWebServer
|
|||||||
app.MapPredictalyticsReadEndpoints();
|
app.MapPredictalyticsReadEndpoints();
|
||||||
app.MapPredictalyticsControlEndpoints();
|
app.MapPredictalyticsControlEndpoints();
|
||||||
|
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug);
|
||||||
|
|
||||||
lock (_lock) { _app = app; }
|
lock (_lock) { _app = app; }
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_runTask = app.RunAsync(_cts.Token);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error(ex, "Failed to start embedded web server");
|
Log.Error(ex, "Failed to start embedded web server");
|
||||||
|
WebServerRunning = false;
|
||||||
|
RaiseStateChanged();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -198,36 +252,51 @@ public class EmbeddedWebServer
|
|||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
|
try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
await (app as IAsyncDisposable).DisposeAsync();
|
await ((IAsyncDisposable)app).DisposeAsync();
|
||||||
Log.Information("Kestrel webserver stopped");
|
Log.Information("Kestrel webserver stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WebServerRunning = false;
|
||||||
|
RaiseStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Worker
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startet die Hintergrund-Worker und laeuft, bis <paramref name="ct"/> ausgeloest wird.
|
||||||
|
/// </summary>
|
||||||
public async Task StartWorkersAsync(CancellationToken ct)
|
public async Task StartWorkersAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Log.Information("Starting workers (no web server)...");
|
Log.Information("Starting workers (no web server)...");
|
||||||
|
WorkersRunning = true;
|
||||||
|
RaiseStateChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var host = Host.CreateDefaultBuilder()
|
var host = Host.CreateDefaultBuilder()
|
||||||
.UseSerilog()
|
.UseSerilog()
|
||||||
.ConfigureServices((ctx, services) =>
|
.ConfigureServices((ctx, services) =>
|
||||||
{
|
{
|
||||||
Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****"));
|
Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}",
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
|
MaskPassword(Options.ConnectionString));
|
||||||
if (!string.IsNullOrEmpty(EgressChannelsText))
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
||||||
{
|
services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug);
|
||||||
var egressOptions = ParseEgressOptions(EgressChannelsText);
|
ApplyEgressOptions(services);
|
||||||
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Channels = egressOptions.Channels;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
services.AddWorkerServices();
|
services.AddWorkerServices();
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, Options.DbConnectionDebug);
|
||||||
await host.RunAsync(ct);
|
await host.RunAsync(ct);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WorkersRunning = false;
|
||||||
|
RaiseStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a one-time full market sync for all supported platforms.
|
/// Performs a one-time full market sync for all supported platforms.
|
||||||
@@ -240,7 +309,8 @@ public class EmbeddedWebServer
|
|||||||
.UseSerilog()
|
.UseSerilog()
|
||||||
.ConfigureServices((ctx, services) =>
|
.ConfigureServices((ctx, services) =>
|
||||||
{
|
{
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
||||||
|
services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug);
|
||||||
})
|
})
|
||||||
.Build();
|
.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<Predictalytics.Infrastructure.Configuration.EgressOptions>(o =>
|
||||||
|
{
|
||||||
|
o.Channels = egressOptions.Channels;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static string? FindWwwrootPath()
|
private static string? FindWwwrootPath()
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
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[]
|
var candidates = new[]
|
||||||
{
|
{
|
||||||
Path.Combine(baseDir, "wwwroot"),
|
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, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot"))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var path in candidates)
|
foreach (var path in candidates)
|
||||||
@@ -332,7 +421,7 @@ public class EmbeddedWebServer
|
|||||||
return null;
|
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();
|
var options = new Predictalytics.Infrastructure.Configuration.EgressOptions();
|
||||||
if (string.IsNullOrWhiteSpace(text)) return options;
|
if (string.IsNullOrWhiteSpace(text)) return options;
|
||||||
+78
-18
@@ -1,32 +1,54 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost;
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
public class AppSettings
|
/// <summary>
|
||||||
|
/// Laufzeit-Konfiguration der Anwendung.
|
||||||
|
/// <para>
|
||||||
|
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
|
||||||
|
/// Basisbibliothek) und werden vom WinForms-PropertyGrid ausgewertet. Die Avalonia-Shell
|
||||||
|
/// kann sie fuer generierte Beschriftungen nutzen oder ignorieren.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PredictalyticsOptions
|
||||||
{
|
{
|
||||||
private const string FileName = "settings.json";
|
private const string FileName = "settings.json";
|
||||||
|
|
||||||
|
// ─── Webserver ───
|
||||||
|
|
||||||
[Category("Webserver")]
|
[Category("Webserver")]
|
||||||
[DisplayName("Port")]
|
[DisplayName("Port")]
|
||||||
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
||||||
[DefaultValue(5000)]
|
[DefaultValue(5000)]
|
||||||
public int WebserverPort { get; set; } = 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")]
|
[Category("Webserver")]
|
||||||
[DisplayName("Database Debug")]
|
[DisplayName("Database Debug")]
|
||||||
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
|
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
|
||||||
[DefaultValue(false)]
|
[DefaultValue(false)]
|
||||||
public bool DbConnectionDebug { get; set; } = false;
|
public bool DbConnectionDebug { get; set; } = false;
|
||||||
|
|
||||||
|
// ─── Egress ───
|
||||||
|
|
||||||
private string _egressChannelsText = "";
|
private string _egressChannelsText = "";
|
||||||
|
|
||||||
[Category("Egress (Proxy/IP)")]
|
[Category("Egress (Proxy/IP)")]
|
||||||
[DisplayName("Egress Channels")]
|
[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")]
|
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). " +
|
||||||
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
|
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
|
||||||
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
||||||
|
|
||||||
|
// ─── Watchdog ───
|
||||||
|
|
||||||
[Category("Watchdog")]
|
[Category("Watchdog")]
|
||||||
[DisplayName("Enabled")]
|
[DisplayName("Enabled")]
|
||||||
[Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")]
|
[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)]
|
[DefaultValue(60)]
|
||||||
public int WatchdogIntervalSeconds { get; set; } = 60;
|
public int WatchdogIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
// ─── Database ───
|
||||||
|
|
||||||
private string _dbServer = "localhost";
|
private string _dbServer = "localhost";
|
||||||
private string _dbName = "";
|
private string _dbName = "";
|
||||||
private string _dbUser = "";
|
private string _dbUser = "";
|
||||||
@@ -85,7 +109,15 @@ public class AppSettings
|
|||||||
[PasswordPropertyText(true)]
|
[PasswordPropertyText(true)]
|
||||||
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
|
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)]
|
[Browsable(false)]
|
||||||
|
[JsonIgnore]
|
||||||
public string ConnectionString
|
public string ConnectionString
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -97,7 +129,7 @@ public class AppSettings
|
|||||||
UserID = DbUser?.Trim(),
|
UserID = DbUser?.Trim(),
|
||||||
Password = DbPassword?.Trim(),
|
Password = DbPassword?.Trim(),
|
||||||
AllowPublicKeyRetrieval = true,
|
AllowPublicKeyRetrieval = true,
|
||||||
SslMode = MySqlConnector.MySqlSslMode.None,
|
SslMode = DbSslMode,
|
||||||
Pooling = true,
|
Pooling = true,
|
||||||
MinimumPoolSize = 0,
|
MinimumPoolSize = 0,
|
||||||
MaximumPoolSize = 100
|
MaximumPoolSize = 100
|
||||||
@@ -106,32 +138,60 @@ public class AppSettings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AppSettings Load()
|
/// <summary>Kestrel-Bind-URL aus Host und Port.</summary>
|
||||||
|
[Browsable(false)]
|
||||||
|
[JsonIgnore]
|
||||||
|
public string WebserverUrl =>
|
||||||
|
$"http://{(string.IsNullOrWhiteSpace(WebserverHost) ? "localhost" : WebserverHost.Trim())}:{WebserverPort}";
|
||||||
|
|
||||||
|
// ─── Persistenz ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ablageort der Einstellungen: %APPDATA%\Predictalytics unter Windows,
|
||||||
|
/// ~/.config/Predictalytics unter Linux. Bewusst nicht neben der Programmdatei —
|
||||||
|
/// deren Verzeichnis ist unter Linux ueblicherweise nicht beschreibbar.
|
||||||
|
/// </summary>
|
||||||
|
public static string SettingsDirectory => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"Predictalytics");
|
||||||
|
|
||||||
|
public static string SettingsFilePath => Path.Combine(SettingsDirectory, FileName);
|
||||||
|
|
||||||
|
/// <summary>Alter Ablageort neben der Programmdatei (bis einschliesslich der WinForms-Fassung).</summary>
|
||||||
|
private static string LegacySettingsFilePath => Path.Combine(AppContext.BaseDirectory, FileName);
|
||||||
|
|
||||||
|
public static PredictalyticsOptions Load()
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
|
// Neuer Ort hat Vorrang; sonst einmalig aus dem alten uebernehmen, damit
|
||||||
if (!File.Exists(filePath))
|
// 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();
|
var fresh = new PredictalyticsOptions();
|
||||||
settings.Save();
|
fresh.Save();
|
||||||
return settings;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(filePath);
|
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
|
||||||
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
|
?? new PredictalyticsOptions();
|
||||||
|
if (path == LegacySettingsFilePath) options.Save(); // Migration festschreiben
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return new AppSettings();
|
return new PredictalyticsOptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
|
Directory.CreateDirectory(SettingsDirectory);
|
||||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
File.WriteAllText(SettingsFilePath,
|
||||||
var json = JsonSerializer.Serialize(this, options);
|
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
File.WriteAllText(filePath, json);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost.Services;
|
namespace Predictalytics.Hosting;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
|
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
using Serilog.Core;
|
using Serilog.Core;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
|
|
||||||
namespace Predictalytics.Infrastructure.Logging;
|
namespace Predictalytics.Infrastructure.Logging;
|
||||||
@@ -7,11 +7,11 @@ namespace Predictalytics.Infrastructure.Logging;
|
|||||||
/// Custom Serilog sink that delegates log writes to a provided action.
|
/// 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).
|
/// The action is responsible for marshaling to the correct thread (e.g. UI thread).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RichTextBoxSink : ILogEventSink
|
public class DelegateSink : ILogEventSink
|
||||||
{
|
{
|
||||||
private readonly Action<string, LogEventLevel> _writeAction;
|
private readonly Action<string, LogEventLevel> _writeAction;
|
||||||
|
|
||||||
public RichTextBoxSink(Action<string, LogEventLevel> writeAction)
|
public DelegateSink(Action<string, LogEventLevel> writeAction)
|
||||||
{
|
{
|
||||||
_writeAction = writeAction;
|
_writeAction = writeAction;
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
using System.Reflection;
|
using Predictalytics.Hosting;
|
||||||
using Predictalytics.WinFormsHost.Services;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost;
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
public partial class MainForm : Form
|
public partial class MainForm : Form
|
||||||
{
|
{
|
||||||
private EmbeddedWebServer? _webServer;
|
private PredictalyticsHost _host = null!;
|
||||||
private CancellationTokenSource? _workerCts;
|
private CancellationTokenSource? _workerCts;
|
||||||
private bool _workerRunning;
|
private PredictalyticsOptions _settings = null!;
|
||||||
private bool _webServerRunning;
|
|
||||||
private AppSettings _settings = null!;
|
|
||||||
private WatchdogHeartbeatService? _watchdog;
|
private WatchdogHeartbeatService? _watchdog;
|
||||||
|
|
||||||
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
|
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
|
||||||
@@ -27,33 +24,31 @@ public partial class MainForm : Form
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called after Serilog is configured. Initializes the embedded web server.
|
/// Called after Serilog is configured. Initializes the application host.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Initialize()
|
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.SelectedObject = _settings;
|
||||||
pg_settings.PropertyValueChanged += (s, e) => {
|
pg_settings.PropertyValueChanged += (s, e) =>
|
||||||
_settings.Save();
|
|
||||||
if (_webServer != null)
|
|
||||||
{
|
{
|
||||||
_webServer.ConnectionString = _settings.ConnectionString;
|
_settings.Save();
|
||||||
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
|
||||||
_webServer.EgressChannelsText = _settings.EgressChannelsText;
|
|
||||||
}
|
|
||||||
RestartWatchdog();
|
RestartWatchdog();
|
||||||
};
|
};
|
||||||
|
|
||||||
_webServer = new EmbeddedWebServer();
|
|
||||||
_webServer.ConnectionString = _settings.ConnectionString;
|
|
||||||
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
|
||||||
_webServer.EgressChannelsText = _settings.EgressChannelsText;
|
|
||||||
|
|
||||||
// Build Version (Date of compilation/file creation)
|
// Build Version (Date of compilation/file creation)
|
||||||
try {
|
try
|
||||||
|
{
|
||||||
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
|
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
|
||||||
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
|
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
|
||||||
} catch {
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
label_buildVersion.Text = "Build: Unknown";
|
label_buildVersion.Text = "Build: Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +60,8 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
Log.Information("MainForm initialized. Ready.");
|
Log.Information("MainForm initialized. Ready.");
|
||||||
Log.Information("Press 'Start Server' to begin polling & discovery.");
|
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();
|
_ = UpdateDbSizeAsync();
|
||||||
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
|
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
|
||||||
@@ -100,28 +96,24 @@ public partial class MainForm : Form
|
|||||||
_settings.WatchdogIntervalSeconds,
|
_settings.WatchdogIntervalSeconds,
|
||||||
metadataProvider: () => new
|
metadataProvider: () => new
|
||||||
{
|
{
|
||||||
workersRunning = _workerRunning,
|
workersRunning = _host.WorkersRunning,
|
||||||
webserverRunning = _webServerRunning
|
webserverRunning = _host.WebServerRunning
|
||||||
});
|
});
|
||||||
_watchdog.Start();
|
_watchdog.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Btn_serverstart_Click(object? sender, EventArgs e)
|
private async void Btn_serverstart_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (!_workerRunning)
|
if (!_host.WorkersRunning)
|
||||||
{
|
{
|
||||||
// Start workers
|
// Start workers
|
||||||
_workerCts = new CancellationTokenSource();
|
_workerCts = new CancellationTokenSource();
|
||||||
_workerRunning = true;
|
|
||||||
btn_serverstart.Text = "⏹ Stop Server";
|
btn_serverstart.Text = "⏹ Stop Server";
|
||||||
Log.Information("🚀 Starting background workers...");
|
Log.Information("🚀 Starting background workers...");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_webServer!.ConnectionString = _settings.ConnectionString;
|
await _host.StartWorkersAsync(_workerCts.Token);
|
||||||
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
|
|
||||||
_webServer!.EgressChannelsText = _settings.EgressChannelsText;
|
|
||||||
await _webServer!.StartWorkersAsync(_workerCts.Token);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
||||||
@@ -131,7 +123,6 @@ public partial class MainForm : Form
|
|||||||
// Stop workers
|
// Stop workers
|
||||||
Log.Information("⏹ Stopping background workers...");
|
Log.Information("⏹ Stopping background workers...");
|
||||||
_workerCts?.Cancel();
|
_workerCts?.Cancel();
|
||||||
_workerRunning = false;
|
|
||||||
btn_serverstart.Text = "▶ Start Server";
|
btn_serverstart.Text = "▶ Start Server";
|
||||||
Log.Information("Workers stopped.");
|
Log.Information("Workers stopped.");
|
||||||
}
|
}
|
||||||
@@ -140,28 +131,25 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
|
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (!_webServerRunning)
|
if (!_host.WebServerRunning)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Log.Information("🌐 Starting embedded Kestrel webserver on http://localhost:{Port}...", _settings.WebserverPort);
|
Log.Information("🌐 Starting embedded Kestrel webserver on {Url}...", _settings.WebserverUrl);
|
||||||
await _webServer!.StartWebServerAsync(_settings.WebserverPort);
|
await _host.StartWebServerAsync();
|
||||||
_webServerRunning = true;
|
|
||||||
btn_localWebserver.Text = "⏹ Stop Webserver";
|
btn_localWebserver.Text = "⏹ Stop Webserver";
|
||||||
Log.Information("✅ WebUI available at http://localhost:{Port}", _settings.WebserverPort);
|
Log.Information("✅ WebUI available at {Url}", _settings.WebserverUrl);
|
||||||
Log.Information("📄 Swagger API docs at http://localhost:{Port}/swagger", _settings.WebserverPort);
|
Log.Information("📄 Swagger API docs at {Url}/swagger", _settings.WebserverUrl);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error(ex, "Failed to start webserver");
|
Log.Error(ex, "Failed to start webserver");
|
||||||
_webServerRunning = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Log.Information("⏹ Stopping webserver...");
|
Log.Information("⏹ Stopping webserver...");
|
||||||
await _webServer!.StopWebServerAsync();
|
await _host.StopWebServerAsync();
|
||||||
_webServerRunning = false;
|
|
||||||
btn_localWebserver.Text = "▶ Start Webserver";
|
btn_localWebserver.Text = "▶ Start Webserver";
|
||||||
Log.Information("Webserver stopped.");
|
Log.Information("Webserver stopped.");
|
||||||
}
|
}
|
||||||
@@ -170,8 +158,10 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
private void UpdateStatusBar()
|
private void UpdateStatusBar()
|
||||||
{
|
{
|
||||||
var workerStatus = _workerRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
|
var workerStatus = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
|
||||||
var serverStatus = _webServerRunning ? $"[RUNNING] Webserver :{_settings.WebserverPort}" : "[STOPPED] Webserver";
|
var serverStatus = _host.WebServerRunning
|
||||||
|
? $"[RUNNING] Webserver :{_settings.WebserverPort}"
|
||||||
|
: "[STOPPED] Webserver";
|
||||||
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
|
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +171,7 @@ public partial class MainForm : Form
|
|||||||
_watchdog?.Dispose();
|
_watchdog?.Dispose();
|
||||||
_watchdog = null;
|
_watchdog = null;
|
||||||
_workerCts?.Cancel();
|
_workerCts?.Cancel();
|
||||||
_webServer?.StopWebServerAsync().GetAwaiter().GetResult();
|
_host?.StopWebServerAsync().GetAwaiter().GetResult();
|
||||||
base.OnFormClosing(e);
|
base.OnFormClosing(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +179,11 @@ public partial class MainForm : Form
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
System.Diagnostics.Process.Start("explorer.exe", $"\"http://localhost:{_settings.WebserverPort}\"");
|
OpenInShell(_settings.WebserverUrl);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,21 +192,26 @@ public partial class MainForm : Form
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs");
|
var logPath = LoggingSetup.DefaultLogDirectory;
|
||||||
if (Directory.Exists(logPath))
|
OpenInShell(Directory.Exists(logPath) ? logPath : Environment.CurrentDirectory);
|
||||||
System.Diagnostics.Process.Start("explorer.exe", logPath);
|
|
||||||
else
|
|
||||||
System.Diagnostics.Process.Start("explorer.exe", Environment.CurrentDirectory);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Serilog.Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
|
Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
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.",
|
MessageBox.Show("Market sync cannot be started while background workers are running.",
|
||||||
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
@@ -230,7 +225,7 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
// Use a temporary CTS for this operation
|
// Use a temporary CTS for this operation
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
await _webServer!.RunSingleMarketSyncAsync(cts.Token);
|
await _host.RunSingleMarketSyncAsync(cts.Token);
|
||||||
|
|
||||||
Log.Information("Manual market sync completed successfully.");
|
Log.Information("Manual market sync completed successfully.");
|
||||||
MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
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)
|
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.",
|
MessageBox.Show("Database update cannot be run while background workers are running.",
|
||||||
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
@@ -259,7 +254,7 @@ public partial class MainForm : Form
|
|||||||
{
|
{
|
||||||
btn_dbUpdate.Enabled = false;
|
btn_dbUpdate.Enabled = false;
|
||||||
Log.Information("Manual database update triggered...");
|
Log.Information("Manual database update triggered...");
|
||||||
await _webServer!.UpdateDatabaseAsync();
|
await _host.UpdateDatabaseAsync();
|
||||||
Log.Information("Database updated successfully.");
|
Log.Information("Database updated successfully.");
|
||||||
MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
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)
|
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.",
|
MessageBox.Show("Recalculation cannot be started while background workers are running. Stop the server first.",
|
||||||
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
@@ -298,7 +293,7 @@ public partial class MainForm : Form
|
|||||||
Log.Information("Manual full recalculation reset triggered...");
|
Log.Information("Manual full recalculation reset triggered...");
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
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.",
|
MessageBox.Show($"Reset complete:\n\n{summary}\n\nNow start the server to rebuild the analytics.",
|
||||||
"Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
"Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
@@ -316,29 +311,10 @@ public partial class MainForm : Form
|
|||||||
|
|
||||||
private async Task UpdateDbSizeAsync()
|
private async Task UpdateDbSizeAsync()
|
||||||
{
|
{
|
||||||
try
|
var sizeMb = await _host.GetDatabaseSizeMbAsync();
|
||||||
{
|
if (IsDisposed) return;
|
||||||
// Build the connection string
|
this.Invoke(() => label_dbSize.Text = sizeMb.HasValue
|
||||||
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(_settings.ConnectionString);
|
? $"DB Size: {sizeMb.Value:F2} MB"
|
||||||
if (string.IsNullOrWhiteSpace(csBuilder.Database))
|
: "DB Size: —");
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
@@ -13,27 +13,27 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- Direkt genutzt: Log.* in Program.cs und MainForm.cs -->
|
||||||
|
<PackageReference Include="Serilog" />
|
||||||
|
<!-- Fuer 'dotnet ef' mit diesem Projekt als Startprojekt -->
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Serilog" />
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.File" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
|
||||||
<PackageReference Include="Serilog.Formatting.Compact" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
|
<!-- Api, Worker, Infrastructure und LicenseLabrador.Client kommen transitiv ueber Hosting. -->
|
||||||
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
</ItemGroup>
|
||||||
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
|
|
||||||
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
|
<ItemGroup>
|
||||||
|
<!-- Die WebUI wird neben die Programmdatei kopiert, damit PredictalyticsHost sie
|
||||||
|
ueber AppContext.BaseDirectory findet — ersetzt die fruehere Pfad-Heuristik. -->
|
||||||
|
<Content Include="..\Predictalytics.Api\wwwroot\**"
|
||||||
|
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
||||||
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
|
CopyToPublishDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="CleanupLocalization" AfterTargets="Build">
|
<Target Name="CleanupLocalization" AfterTargets="Build">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Predictalytics.Infrastructure.Logging;
|
using Predictalytics.Hosting;
|
||||||
using Predictalytics.WinFormsHost.Services;
|
using Predictalytics.WinFormsHost.Services;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost;
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
@@ -13,149 +12,33 @@ internal static class Program
|
|||||||
ApplicationConfiguration.Initialize();
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
// ─── License gate: no usable license, no app ───
|
// ─── License gate: no usable license, no app ───
|
||||||
var licenseClient = LicenseGuard.EnsureLicensed();
|
var licenseClient = LicenseGate.EnsureLicensed();
|
||||||
if (licenseClient == null)
|
if (licenseClient == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mainForm = new MainForm();
|
var mainForm = new MainForm();
|
||||||
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
|
|
||||||
|
|
||||||
// ─── Output template matching terminal format ───
|
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier wird lediglich der
|
||||||
const string textTemplate =
|
// Terminal-Sink beigesteuert, der auf den UI-Thread marshallt.
|
||||||
"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
|
LoggingSetup.Configure(TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm));
|
||||||
|
LoggingSetup.LogStartupBanner();
|
||||||
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("══════════════════════════════════════════════════════");
|
|
||||||
|
|
||||||
mainForm.Initialize();
|
mainForm.Initialize();
|
||||||
|
|
||||||
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
|
// 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);
|
System.Windows.Forms.Application.Run(mainForm);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using LicenseLabrador.Client;
|
||||||
|
using Predictalytics.Hosting;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WinForms-seitige Lizenzschranke: verbindet den plattformneutralen
|
||||||
|
/// <see cref="LicenseGuard"/> mit dem interaktiven Aktivierungsdialog.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LicenseGate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using LicenseLabrador.Client;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.WinFormsHost.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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" };
|
|
||||||
|
|
||||||
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app
|
|
||||||
/// keeps running; on a definitively unusable license the app is shut down.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user