diff --git a/Directory.Packages.props b/Directory.Packages.props
index 883efc0..2584b89 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -57,6 +57,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Predictalytics.slnx b/Predictalytics.slnx
index c9d46b3..4ea0f1a 100644
--- a/Predictalytics.slnx
+++ b/Predictalytics.slnx
@@ -6,6 +6,7 @@
+
diff --git a/docs/PLAN-Linux-Portierung.md b/docs/PLAN-Linux-Portierung.md
index 0dfa4fa..fc748f3 100644
--- a/docs/PLAN-Linux-Portierung.md
+++ b/docs/PLAN-Linux-Portierung.md
@@ -298,7 +298,68 @@ ausschließlich UI-Code. `Predictalytics.Hosting` baut ohne Windows-Referenzen.
---
-## 5. Phase 3 — Avalonia-Bedienhülle (4–6 PT)
+## 5. Phase 3 — Avalonia-Bedienhülle ✅ **erledigt (2026-08-07)**
+
+**Abnahme:** `dotnet build` → 0 Fehler, 0 Warnungen. `dotnet test` → 100 bestanden.
+Beide Betriebsarten laufen an:
+
+| Prüfung | Ergebnis |
+|---|---|
+| `Predictalytics.Shell.exe --headless` | Logging läuft, Lizenzprüfung greift, sauberer Abbruch mit **Exit-Code 2** und handlungsfähiger Meldung |
+| `Predictalytics.Shell.exe` (GUI) | startet, Lizenzfenster erscheint, kein Absturz |
+| Konsolen-Kodierung | UTF-8 explizit gesetzt — ohne das zeigt die Windows-Konsole nur Fragezeichen statt Rahmen und Emoji |
+
+### ⚠️ Nicht verifiziert: das Hauptfenster
+
+Die hinterlegte Lizenz ist **abgelaufen** (`Expired: License has expired`). Beide
+Betriebsarten bleiben deshalb an der Lizenzschranke stehen — `MainWindow`, Terminal,
+Einstellungsansicht und die Wartungsaktionen sind compilergeprüft, aber **noch nie
+angezeigt worden**. Nach Erneuerung der Lizenz nachzuholen.
+
+### Umsetzung
+
+| Datei | Zweck |
+|---|---|
+| `Program.cs` | Einstiegspunkt. Argumentauswertung **vor** jeder Avalonia-Initialisierung — sonst stirbt der Prozess auf einem Server ohne X11, bevor `--headless` greift. |
+| `App.axaml(.cs)` | Lizenzschranke, dann Hauptfenster. `ShutdownMode.OnExplicitShutdown` während der Aktivierung, danach `OnMainWindowClose`. |
+| `ViewModels/MainWindowViewModel.cs` | Bindungsziel, `CommunityToolkit.Mvvm`-Quellgeneratoren, kapselt `PredictalyticsHost` |
+| `ViewModels/LogLine.cs` | Terminalzeile mit Einfärbung nach Loglevel (Farbschema aus WinForms übernommen) |
+| `Views/MainWindow.axaml` | Menü, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab, Statusleiste |
+| `Views/LicenseWindow.axaml` | Aktivierungsfenster, ersetzt `LicenseDialog` |
+| `Views/Dialogs.cs` | Ersatz für `MessageBox.Show` — im Code aufgebaut, kein Drittanbieterpaket |
+| `Services/HeadlessRunner.cs` | AP 3.4: Betrieb ohne Oberfläche |
+| `Services/ConsoleAttach.cs` | Windows-Konsole anhängen + UTF-8 |
+
+### Abweichungen und Entscheidungen
+
+* **`PropertyGrid`-Ersatz handgeschrieben**, wie empfohlen. Vier Gruppen als
+ `HeaderedContentControl`, Passwortfelder mit `PasswordChar`, **echtes mehrzeiliges
+ Textfeld** für die Egress-Kanäle (besser als das in Phase 2 entfallene `[Editor]`-Attribut).
+ SSL-Modus als `ComboBox`; bewusst nur `None`/`Preferred`/`Required` — `VerifyCA`/`VerifyFull`
+ bräuchten hinterlegte Zertifikate und würden hier nur zu Fehlkonfiguration einladen.
+* **Speichern ist jetzt explizit** („Speichern & übernehmen") statt wie beim `PropertyGrid`
+ bei jeder Einzeländerung. Klarer, und erspart `INotifyPropertyChanged` auf `PredictalyticsOptions`.
+* **Feste Fenstergröße aufgelöst** (vorher `1886×1088` fix): jetzt `MinWidth`/`MinHeight`
+ mit skalierendem `DockPanel`/`Grid`.
+* **Schrift-Fallbackkette** `Cascadia Code, DejaVu Sans Mono, Liberation Mono, Consolas, monospace`
+ fürs Terminal; `Avalonia.Fonts.Inter` als mitgelieferte UI-Schrift, weil Linux-Distributionen
+ sehr unterschiedliche Standardfonts haben.
+* **Ringpuffer statt Leeren**: die WinForms-Fassung verwarf bei 500 Zeilen das gesamte
+ Terminal (`rtb.Clear()`), jetzt fällt jeweils nur die älteste Zeile heraus.
+* **`Avalonia.Diagnostics` nicht referenziert** — gibt es nur bis 11.3.18, nicht für 12.x.
+* **`global::Avalonia.Application`**: innerhalb von `Predictalytics.*` löst der kurze Name
+ `Application` auf den eigenen Namespace `Predictalytics.Application` auf. Betrifft jede
+ künftige Avalonia-Klasse in diesem Projekt.
+* **`Microsoft.NET.Sdk` statt `Sdk.Web`**, wie in Phase 2 gelernt (Static-Web-Assets-Kollision
+ mit dem `wwwroot`-Content-Eintrag).
+
+### Headless-Betrieb
+
+Lizenz über `PREDICTALYTICS_LICENSE_KEY`; ohne nutzbare Lizenz Abbruch mit Exit-Code 2
+statt eines Dialogs, den niemand sieht. `SIGTERM` und `SIGINT` werden über
+`PosixSignalRegistration` abgefangen, damit systemd sauber stoppen kann.
+
+Exit-Codes: `0` planmäßig beendet · `2` keine nutzbare Lizenz · `3` Abbruch mit Fehler.
### AP 3.1 — Projekt `Predictalytics.Shell`
```xml
diff --git a/src/Predictalytics.Shell/App.axaml b/src/Predictalytics.Shell/App.axaml
new file mode 100644
index 0000000..93a0f4d
--- /dev/null
+++ b/src/Predictalytics.Shell/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/src/Predictalytics.Shell/App.axaml.cs b/src/Predictalytics.Shell/App.axaml.cs
new file mode 100644
index 0000000..449b06d
--- /dev/null
+++ b/src/Predictalytics.Shell/App.axaml.cs
@@ -0,0 +1,92 @@
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using Predictalytics.Hosting;
+using Predictalytics.Shell.ViewModels;
+using Predictalytics.Shell.Views;
+using Serilog;
+
+namespace Predictalytics.Shell;
+
+///
+/// Avalonia-Anwendungsobjekt.
+///
+/// Die Basisklasse wird voll qualifiziert angegeben: innerhalb von
+/// Predictalytics.* loest der kurze Name Application auf den
+/// Namespace Predictalytics.Application auf, nicht auf den Avalonia-Typ.
+///
+///
+public partial class App : global::Avalonia.Application
+{
+ private IDisposable? _licenseWatch;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // Das Lizenzfenster darf beim Schliessen nicht die Anwendung beenden —
+ // danach soll ja erst das Hauptfenster kommen.
+ desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
+ desktop.Exit += (_, _) => _licenseWatch?.Dispose();
+
+ // Nicht awaiten: OnFrameworkInitializationCompleted muss zurueckkehren,
+ // damit die Nachrichtenschleife anlaufen kann.
+ Dispatcher.UIThread.Post(async () => await StartupAsync(desktop));
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
+ var client = LicenseGuard.CreateClient();
+ var result = await LicenseGuard.RevalidateAsync(client);
+
+ if (!LicenseGuard.IsUsable(client, result))
+ {
+ var licenseWindow = new LicenseWindow(client, result);
+ desktop.MainWindow = licenseWindow;
+ licenseWindow.Show();
+
+ if (!await licenseWindow.Completion)
+ {
+ desktop.Shutdown();
+ return;
+ }
+ }
+
+ // ─── Hauptfenster ───
+ var viewModel = new MainWindowViewModel();
+ var window = new MainWindow(viewModel);
+
+ // Serilog-Aufbau liegt in Predictalytics.Hosting; hier kommt nur der
+ // Terminal-Sink dazu, der selbst auf den UI-Thread marshallt.
+ LoggingSetup.Configure(viewModel.AppendLog);
+ LoggingSetup.LogStartupBanner();
+
+ desktop.MainWindow = window;
+ desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
+ window.Show();
+
+ viewModel.Start();
+
+ // Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
+ _licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, unusable =>
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
+ $"Die Lizenz ist nicht mehr gültig ({unusable.State}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
+ desktop.Shutdown();
+ }));
+
+ desktop.Exit += (_, _) =>
+ {
+ Log.Information("Application shutting down.");
+ Log.CloseAndFlush();
+ };
+ }
+}
diff --git a/src/Predictalytics.Shell/Predictalytics.Shell.csproj b/src/Predictalytics.Shell/Predictalytics.Shell.csproj
new file mode 100644
index 0000000..f5564e6
--- /dev/null
+++ b/src/Predictalytics.Shell/Predictalytics.Shell.csproj
@@ -0,0 +1,37 @@
+
+
+
+
+ WinExe
+ Predictalytics.Shell
+ true
+ true
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Predictalytics.Shell/Program.cs b/src/Predictalytics.Shell/Program.cs
new file mode 100644
index 0000000..8892a19
--- /dev/null
+++ b/src/Predictalytics.Shell/Program.cs
@@ -0,0 +1,29 @@
+using Avalonia;
+using Predictalytics.Shell.Services;
+
+namespace Predictalytics.Shell;
+
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ // Die Argumentauswertung steht bewusst vor jeder Avalonia-Initialisierung:
+ // auf einem Server ohne X11 wuerde der Prozess sonst beim Aufbau des
+ // Fenstersystems sterben, bevor --headless ueberhaupt greift.
+ if (args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase)))
+ {
+ ConsoleAttach.TryAttachToParent();
+ return HeadlessRunner.RunAsync().GetAwaiter().GetResult();
+ }
+
+ return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ /// Wird auch vom Avalonia-Designer verwendet.
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/Predictalytics.Shell/Services/ConsoleAttach.cs b/src/Predictalytics.Shell/Services/ConsoleAttach.cs
new file mode 100644
index 0000000..bf8a2ac
--- /dev/null
+++ b/src/Predictalytics.Shell/Services/ConsoleAttach.cs
@@ -0,0 +1,39 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text;
+
+namespace Predictalytics.Shell.Services;
+
+///
+/// Konsolen-Vorbereitung fuer den Headless-Modus.
+///
+/// Das Projekt ist als WinExe gebaut, damit unter Windows kein Konsolenfenster
+/// hinter der GUI steht. Im Headless-Modus fehlt dadurch aber die Ausgabe, wenn
+/// aus einer Eingabeaufforderung gestartet wird —
+/// haengt den Prozess an die Konsole des aufrufenden Prozesses an.
+///
+/// Unter Linux ist WinExe gleichbedeutend mit Exe; dort entfaellt das Anhaengen.
+///
+internal static class ConsoleAttach
+{
+ private const int AttachParentProcess = -1;
+
+ public static void TryAttachToParent()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ try { AttachConsole(AttachParentProcess); }
+ catch { /* ohne Konsole bleiben die Logdateien */ }
+ }
+
+ // Die Logausgabe enthaelt Rahmenzeichen und Emoji. Linux-Terminals sind
+ // ohnehin UTF-8, die Windows-Konsole laeuft ohne das hier auf der
+ // ANSI-Codepage und zeigt nur Fragezeichen.
+ try { Console.OutputEncoding = Encoding.UTF8; }
+ catch { /* keine Konsole angehaengt — irrelevant */ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool AttachConsole(int dwProcessId);
+}
diff --git a/src/Predictalytics.Shell/Services/HeadlessRunner.cs b/src/Predictalytics.Shell/Services/HeadlessRunner.cs
new file mode 100644
index 0000000..55f9f6d
--- /dev/null
+++ b/src/Predictalytics.Shell/Services/HeadlessRunner.cs
@@ -0,0 +1,165 @@
+using System.Runtime.InteropServices;
+using Predictalytics.Hosting;
+using Serilog;
+
+namespace Predictalytics.Shell.Services;
+
+///
+/// Betrieb ohne Oberflaeche — fuer Linux-Server ohne Desktop-Session
+/// (systemd, Container). Startet Webserver und Worker und laeuft, bis
+/// SIGTERM oder Strg+C eintrifft.
+///
+internal static class HeadlessRunner
+{
+ /// Umgebungsvariable fuer die Aktivierung ohne Dialog.
+ private const string LicenseKeyVariable = "PREDICTALYTICS_LICENSE_KEY";
+
+ public const int ExitOk = 0;
+ public const int ExitNoLicense = 2;
+ public const int ExitFailed = 3;
+
+ public static async Task RunAsync()
+ {
+ var options = PredictalyticsOptions.Load();
+
+ // Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
+ LoggingSetup.Configure();
+ LoggingSetup.LogStartupBanner();
+ Log.Information("Headless-Modus. Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
+
+ try
+ {
+ var client = await EnsureLicensedAsync();
+ if (client == null) return ExitNoLicense;
+
+ var host = new PredictalyticsHost(options);
+ using var cts = new CancellationTokenSource();
+ using var shutdownSignals = RegisterShutdown(cts);
+
+ using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, result =>
+ {
+ Log.Fatal("Lizenz nicht mehr gültig ({State}): {Message} — Dienst wird beendet.",
+ result.State, result.Message);
+ cts.Cancel();
+ });
+
+ using var watchdog = StartWatchdog(options, host);
+
+ await host.StartWebServerAsync();
+ Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
+
+ // Laeuft, bis abgebrochen wird.
+ await host.StartWorkersAsync(cts.Token);
+
+ watchdog?.NotifyStopping();
+ await host.StopWebServerAsync();
+ Log.Information("Dienst planmäßig beendet.");
+ return ExitOk;
+ }
+ catch (OperationCanceledException)
+ {
+ Log.Information("Dienst planmäßig beendet.");
+ return ExitOk;
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal(ex, "Headless-Betrieb abgebrochen.");
+ return ExitFailed;
+ }
+ finally
+ {
+ await Log.CloseAndFlushAsync();
+ }
+ }
+
+ ///
+ /// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
+ /// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
+ ///
+ private static async Task EnsureLicensedAsync()
+ {
+ var client = LicenseGuard.CreateClient();
+
+ var result = await LicenseGuard.RevalidateAsync(client);
+ if (LicenseGuard.IsUsable(client, result)) return client;
+
+ var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ Log.Fatal("Keine nutzbare Lizenz ({State}: {Message}) und {Variable} ist nicht gesetzt. " +
+ "Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
+ "über die Umgebungsvariable bereitstellen.",
+ result.State, result.Message, LicenseKeyVariable);
+ return null;
+ }
+
+ Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
+ result = await client.ValidateAsync(key.Trim().ToUpperInvariant());
+ if (LicenseGuard.IsUsable(client, result))
+ {
+ Log.Information("Lizenz aktiviert ({State}).", result.State);
+ return client;
+ }
+
+ Log.Fatal("Aktivierung fehlgeschlagen ({State}): {Message}", result.State, result.Message);
+ return null;
+ }
+
+ private static WatchdogHeartbeatService? StartWatchdog(PredictalyticsOptions options, PredictalyticsHost host)
+ {
+ if (!options.WatchdogEnabled) return null;
+ if (string.IsNullOrWhiteSpace(options.WatchdogApiKey) || string.IsNullOrWhiteSpace(options.WatchdogUrl))
+ {
+ Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen.");
+ return null;
+ }
+
+ var watchdog = new WatchdogHeartbeatService(
+ options.WatchdogUrl,
+ options.WatchdogApiKey,
+ options.WatchdogSource,
+ options.WatchdogInstance,
+ options.WatchdogIntervalSeconds,
+ metadataProvider: () => new
+ {
+ workersRunning = host.WorkersRunning,
+ webserverRunning = host.WebServerRunning
+ });
+ watchdog.Start();
+ return watchdog;
+ }
+
+ ///
+ /// SIGTERM (systemd stop), SIGINT und Strg+C sauber behandeln, damit der Dienst
+ /// nicht hart abgeschossen wird.
+ ///
+ private static IDisposable RegisterShutdown(CancellationTokenSource cts)
+ {
+ var registrations = new List();
+
+ void Handle(PosixSignalContext context)
+ {
+ context.Cancel = true; // Standardverhalten (sofortiges Beenden) unterdruecken
+ Log.Information("Signal {Signal} empfangen — fahre herunter…", context.Signal);
+ cts.Cancel();
+ }
+
+ registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGTERM, Handle));
+ registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGINT, Handle));
+
+ Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
+
+ return new CompositeDisposable(registrations);
+ }
+
+ private sealed class CompositeDisposable(List items) : IDisposable
+ {
+ public void Dispose()
+ {
+ foreach (var item in items)
+ {
+ try { item.Dispose(); } catch { /* beendet sich ohnehin */ }
+ }
+ }
+ }
+}
diff --git a/src/Predictalytics.Shell/ViewModels/LogLine.cs b/src/Predictalytics.Shell/ViewModels/LogLine.cs
new file mode 100644
index 0000000..73da10f
--- /dev/null
+++ b/src/Predictalytics.Shell/ViewModels/LogLine.cs
@@ -0,0 +1,26 @@
+using Avalonia.Media;
+using Serilog.Events;
+
+namespace Predictalytics.Shell.ViewModels;
+
+/// Eine Zeile der Terminalanzeige samt Einfaerbung nach Loglevel.
+public sealed class LogLine
+{
+ public string Text { get; }
+ public IBrush Foreground { get; }
+
+ public LogLine(string text, LogEventLevel level)
+ {
+ Text = text;
+ Foreground = BrushFor(level);
+ }
+
+ // Farbschema unveraendert aus der WinForms-Fassung uebernommen.
+ private static IBrush BrushFor(LogEventLevel level) => level switch
+ {
+ LogEventLevel.Error or LogEventLevel.Fatal => new SolidColorBrush(Color.FromRgb(255, 82, 82)),
+ LogEventLevel.Warning => new SolidColorBrush(Color.FromRgb(255, 193, 7)),
+ LogEventLevel.Debug or LogEventLevel.Verbose => new SolidColorBrush(Color.FromRgb(158, 158, 158)),
+ _ => new SolidColorBrush(Color.FromRgb(76, 175, 80))
+ };
+}
diff --git a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..e7bf330
--- /dev/null
+++ b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,312 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Predictalytics.Hosting;
+using Serilog;
+using Serilog.Events;
+
+namespace Predictalytics.Shell.ViewModels;
+
+///
+/// Bindungsziel des Hauptfensters. Haelt den und
+/// uebersetzt dessen Zustand in Anzeigewerte.
+///
+public sealed partial class MainWindowViewModel : ObservableObject
+{
+ /// Obergrenze der Terminalzeilen. Aeltere werden vorne verworfen.
+ private const int MaxLogLines = 500;
+
+ private readonly PredictalyticsHost _host;
+ private CancellationTokenSource? _workerCts;
+ private WatchdogHeartbeatService? _watchdog;
+
+ /// Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.
+ public Func? ShowInfo { get; set; }
+ public Func? ShowError { get; set; }
+ public Func>? ShowConfirm { get; set; }
+
+ public PredictalyticsOptions Options { get; }
+
+ public ObservableCollection LogLines { get; } = [];
+
+ [ObservableProperty] private string _statusText = "";
+ [ObservableProperty] private string _dbSizeText = "DB Size: —";
+ [ObservableProperty] private string _buildVersionText = "Build: —";
+ [ObservableProperty] private string _serverButtonText = "▶ Start Server";
+ [ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
+ [ObservableProperty] private bool _isBusy;
+
+ public MainWindowViewModel()
+ {
+ Options = PredictalyticsOptions.Load();
+ _host = new PredictalyticsHost(Options);
+ _host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
+
+ try
+ {
+ var buildDate = new FileInfo(GetType().Assembly.Location).LastWriteTime;
+ BuildVersionText = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
+ }
+ catch
+ {
+ BuildVersionText = "Build: Unknown";
+ }
+
+ UpdateStatus();
+ }
+
+ /// Wird nach dem Serilog-Aufbau aufgerufen.
+ public void Start()
+ {
+ Log.Information("Shell initialisiert. Bereit.");
+ Log.Information("'Start Server' beginnt Polling und Discovery.");
+ Log.Information("'Start Webserver' startet die WebUI auf {Url}", Options.WebserverUrl);
+ Log.Information("Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
+
+ _ = RefreshDbSizeAsync();
+ var dbSizeTimer = new DispatcherTimer { Interval = TimeSpan.FromHours(6) };
+ dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
+ dbSizeTimer.Start();
+
+ RestartWatchdog();
+ }
+
+ /// Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.
+ public void AppendLog(string message, LogEventLevel level)
+ {
+ var text = message.TrimEnd('\r', '\n');
+ Dispatcher.UIThread.Post(() =>
+ {
+ LogLines.Add(new LogLine(text, level));
+ while (LogLines.Count > MaxLogLines) LogLines.RemoveAt(0);
+ });
+ }
+
+ private void UpdateStatus()
+ {
+ var workers = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
+ var web = _host.WebServerRunning ? $"[RUNNING] Webserver :{Options.WebserverPort}" : "[STOPPED] Webserver";
+ StatusText = $"{workers} | {web}";
+ ServerButtonText = _host.WorkersRunning ? "⏹ Stop Server" : "▶ Start Server";
+ WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
+ }
+
+ // ─── Watchdog ───
+
+ private void RestartWatchdog()
+ {
+ _watchdog?.Dispose();
+ _watchdog = null;
+
+ if (!Options.WatchdogEnabled) return;
+
+ if (string.IsNullOrWhiteSpace(Options.WatchdogApiKey) || string.IsNullOrWhiteSpace(Options.WatchdogUrl))
+ {
+ Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Einstellungen eintragen.");
+ return;
+ }
+
+ _watchdog = new WatchdogHeartbeatService(
+ Options.WatchdogUrl,
+ Options.WatchdogApiKey,
+ Options.WatchdogSource,
+ Options.WatchdogInstance,
+ Options.WatchdogIntervalSeconds,
+ metadataProvider: () => new
+ {
+ workersRunning = _host.WorkersRunning,
+ webserverRunning = _host.WebServerRunning
+ });
+ _watchdog.Start();
+ }
+
+ // ─── Befehle ───
+
+ [RelayCommand]
+ private async Task ToggleServerAsync()
+ {
+ if (!_host.WorkersRunning)
+ {
+ _workerCts = new CancellationTokenSource();
+ Log.Information("🚀 Starte Hintergrund-Worker...");
+ try
+ {
+ await _host.StartWorkersAsync(_workerCts.Token);
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex) { Log.Error(ex, "Worker error"); }
+ }
+ else
+ {
+ Log.Information("⏹ Stoppe Hintergrund-Worker...");
+ _workerCts?.Cancel();
+ Log.Information("Worker gestoppt.");
+ }
+ UpdateStatus();
+ }
+
+ [RelayCommand]
+ private async Task ToggleWebserverAsync()
+ {
+ if (!_host.WebServerRunning)
+ {
+ try
+ {
+ Log.Information("🌐 Starte Kestrel auf {Url}...", Options.WebserverUrl);
+ await _host.StartWebServerAsync();
+ Log.Information("✅ WebUI erreichbar unter {Url}", Options.WebserverUrl);
+ Log.Information("📄 Swagger unter {Url}/swagger", Options.WebserverUrl);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Webserver konnte nicht gestartet werden");
+ if (ShowError != null) await ShowError("Fehler", $"Webserver konnte nicht gestartet werden:\n{ex.Message}");
+ }
+ }
+ else
+ {
+ Log.Information("⏹ Stoppe Webserver...");
+ await _host.StopWebServerAsync();
+ Log.Information("Webserver gestoppt.");
+ }
+ UpdateStatus();
+ }
+
+ [RelayCommand]
+ private void SaveSettings()
+ {
+ Options.Save();
+ RestartWatchdog();
+ Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
+ UpdateStatus();
+ }
+
+ [RelayCommand]
+ private void OpenWebUi() => OpenInShell(Options.WebserverUrl);
+
+ [RelayCommand]
+ private void OpenLogFolder()
+ {
+ var path = LoggingSetup.DefaultLogDirectory;
+ OpenInShell(Directory.Exists(path) ? path : AppContext.BaseDirectory);
+ }
+
+ [RelayCommand]
+ private async Task SyncMarketsAsync()
+ {
+ if (!await GuardWorkersStoppedAsync("Der Markt-Sync")) return;
+
+ try
+ {
+ IsBusy = true;
+ Log.Information("Manueller Markt-Sync gestartet...");
+ using var cts = new CancellationTokenSource();
+ await _host.RunSingleMarketSyncAsync(cts.Token);
+ Log.Information("Manueller Markt-Sync abgeschlossen.");
+ if (ShowInfo != null) await ShowInfo("Fertig", "Markt-Sync abgeschlossen.");
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Manueller Markt-Sync fehlgeschlagen");
+ if (ShowError != null) await ShowError("Fehler", $"Markt-Sync fehlgeschlagen:\n{ex.Message}");
+ }
+ finally { IsBusy = false; }
+ }
+
+ [RelayCommand]
+ private async Task UpdateDatabaseAsync()
+ {
+ if (!await GuardWorkersStoppedAsync("Das Datenbank-Update")) return;
+
+ try
+ {
+ IsBusy = true;
+ Log.Information("Manuelles Datenbank-Update gestartet...");
+ await _host.UpdateDatabaseAsync();
+ Log.Information("Datenbank aktualisiert.");
+ if (ShowInfo != null) await ShowInfo("Fertig", "Datenbank-Update abgeschlossen.");
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Manuelles Datenbank-Update fehlgeschlagen");
+ if (ShowError != null) await ShowError("Fehler", $"Datenbank-Update fehlgeschlagen:\n{ex.Message}");
+ }
+ finally { IsBusy = false; }
+ }
+
+ [RelayCommand]
+ private async Task RecalculateAllAsync()
+ {
+ if (!await GuardWorkersStoppedAsync("Die Neuberechnung")) return;
+
+ if (ShowConfirm == null) return;
+ var confirmed = await ShowConfirm("Alle Trader neu berechnen",
+ "Dies löscht alle ABGELEITETEN Analysedaten (Positionen, Tages-Snapshots, " +
+ "Kategorie-Statistiken) und markiert jeden Trader zur vollständigen Neuberechnung.\n\n" +
+ "Rohdaten (Trades und Märkte) bleiben unberührt.\n\n" +
+ "Danach den Server starten: der Analytics-Worker baut jeden Trader mit der " +
+ "aktuellen Engine neu auf (läuft im Hintergrund, kann bei vielen Tradern " +
+ "mehrere Stunden dauern).\n\nFortfahren?");
+ if (!confirmed) return;
+
+ try
+ {
+ IsBusy = true;
+ Log.Information("Vollständige Neuberechnung angestoßen...");
+ using var cts = new CancellationTokenSource();
+ var summary = await _host.RunRecalculateAllTradersAsync(cts.Token);
+ if (ShowInfo != null)
+ await ShowInfo("Alle Trader neu berechnen",
+ $"Zurücksetzen abgeschlossen:\n\n{summary}\n\nJetzt den Server starten, um die Analytik neu aufzubauen.");
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Vollständige Neuberechnung fehlgeschlagen");
+ if (ShowError != null) await ShowError("Fehler", $"Neuberechnung fehlgeschlagen:\n{ex.Message}");
+ }
+ finally { IsBusy = false; }
+ }
+
+ // ─── Hilfsfunktionen ───
+
+ private async Task GuardWorkersStoppedAsync(string action)
+ {
+ if (!_host.WorkersRunning) return true;
+ if (ShowError != null)
+ await ShowError("Worker aktiv", $"{action} kann nicht laufen, während die Hintergrund-Worker aktiv sind. Bitte zuerst den Server stoppen.");
+ return false;
+ }
+
+ private async Task RefreshDbSizeAsync()
+ {
+ var sizeMb = await _host.GetDatabaseSizeMbAsync();
+ DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
+ }
+
+ ///
+ /// Oeffnet Pfad oder URL mit der Standardanwendung des Systems.
+ /// UseShellExecute funktioniert unter Windows wie unter Linux (dort ueber xdg-open).
+ ///
+ private static void OpenInShell(string target)
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Konnte {Target} nicht öffnen", target);
+ }
+ }
+
+ /// Beim Beenden: Watchdog abmelden, Worker und Webserver stoppen.
+ public void Shutdown()
+ {
+ _watchdog?.NotifyStopping();
+ _watchdog?.Dispose();
+ _watchdog = null;
+ _workerCts?.Cancel();
+ try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
+ }
+}
diff --git a/src/Predictalytics.Shell/ViewModels/OptionSources.cs b/src/Predictalytics.Shell/ViewModels/OptionSources.cs
new file mode 100644
index 0000000..24040ab
--- /dev/null
+++ b/src/Predictalytics.Shell/ViewModels/OptionSources.cs
@@ -0,0 +1,19 @@
+using MySqlConnector;
+
+namespace Predictalytics.Shell.ViewModels;
+
+/// Feste Auswahllisten fuer die Einstellungsansicht.
+public static class OptionSources
+{
+ ///
+ /// Die praktisch relevanten SSL-Modi. Die uebrigen Werte des Enums
+ /// (VerifyCA, VerifyFull) erfordern hinterlegte Zertifikate und wuerden
+ /// hier nur zu Fehlkonfiguration einladen.
+ ///
+ public static MySqlSslMode[] SslModes { get; } =
+ [
+ MySqlSslMode.None,
+ MySqlSslMode.Preferred,
+ MySqlSslMode.Required
+ ];
+}
diff --git a/src/Predictalytics.Shell/Views/Dialogs.cs b/src/Predictalytics.Shell/Views/Dialogs.cs
new file mode 100644
index 0000000..ba815fe
--- /dev/null
+++ b/src/Predictalytics.Shell/Views/Dialogs.cs
@@ -0,0 +1,79 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace Predictalytics.Shell.Views;
+
+///
+/// Minimale Ersatzdialoge fuer MessageBox.Show. Bewusst im Code aufgebaut
+/// und ohne Drittanbieterpaket — es sind nur zwei Varianten noetig.
+///
+public static class Dialogs
+{
+ public static Task ShowInfoAsync(Window owner, string title, string message) =>
+ ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
+
+ public static Task ShowErrorAsync(Window owner, string title, string message) =>
+ ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
+
+ public static async Task ShowConfirmAsync(Window owner, string title, string message) =>
+ await ShowAsync(owner, title, message, ["Ja", "Abbrechen"]) == 0;
+
+ private static Task ShowAsync(Window owner, string title, string message, string[] buttons)
+ {
+ var tcs = new TaskCompletionSource();
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Spacing = 8
+ };
+
+ var dialog = new Window
+ {
+ Title = title,
+ SizeToContent = SizeToContent.WidthAndHeight,
+ MaxWidth = 640,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ CanResize = false,
+ ShowInTaskbar = false
+ };
+
+ for (var i = 0; i < buttons.Length; i++)
+ {
+ var index = i;
+ var button = new Button
+ {
+ Content = buttons[i],
+ MinWidth = 90,
+ IsDefault = index == 0,
+ IsCancel = index == buttons.Length - 1
+ };
+ button.Click += (_, _) => { tcs.TrySetResult(index); dialog.Close(); };
+ buttonPanel.Children.Add(button);
+ }
+
+ dialog.Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 16,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = message,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 580
+ },
+ buttonPanel
+ }
+ };
+
+ // Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
+ dialog.Closed += (_, _) => tcs.TrySetResult(buttons.Length - 1);
+
+ _ = dialog.ShowDialog(owner);
+ return tcs.Task;
+ }
+}
diff --git a/src/Predictalytics.Shell/Views/LicenseWindow.axaml b/src/Predictalytics.Shell/Views/LicenseWindow.axaml
new file mode 100644
index 0000000..2bdd849
--- /dev/null
+++ b/src/Predictalytics.Shell/Views/LicenseWindow.axaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs b/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs
new file mode 100644
index 0000000..1cb524a
--- /dev/null
+++ b/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs
@@ -0,0 +1,91 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using LicenseLabrador.Client;
+using Predictalytics.Hosting;
+
+namespace Predictalytics.Shell.Views;
+
+///
+/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
+/// liefert true, sobald eine gueltige, pruefsummen-
+/// verifizierte Lizenz vorliegt; false, wenn der Benutzer abbricht.
+///
+public partial class LicenseWindow : Window
+{
+ private readonly LicenseClient _client = null!;
+ private readonly TaskCompletionSource _completion = new();
+
+ /// Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.
+ public Task Completion => _completion.Task;
+
+ public LicenseWindow() => InitializeComponent();
+
+ public LicenseWindow(LicenseClient client, LicenseResult? lastResult) : this()
+ {
+ _client = client;
+
+ if (lastResult != null && lastResult.State != LicenseState.NoLicense)
+ {
+ SetStatus($"Letzte Prüfung: {lastResult.State} — {lastResult.Message}", isError: true);
+ }
+ }
+
+ private async void OnActivateClick(object? sender, RoutedEventArgs e)
+ {
+ var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? "";
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ SetStatus("Bitte einen Lizenzschlüssel eingeben.", isError: true);
+ return;
+ }
+
+ ActivateButton.IsEnabled = false;
+ Busy.IsVisible = true;
+ SetStatus("Prüfe Lizenz am Server…", isError: false);
+
+ try
+ {
+ var result = await _client.ValidateAsync(key);
+ if (LicenseGuard.IsUsable(_client, result))
+ {
+ _completion.TrySetResult(true);
+ Close();
+ return;
+ }
+
+ SetStatus($"Lizenz nicht nutzbar ({result.State}):\n{result.Message}", isError: true);
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Fehler bei der Prüfung: {ex.Message}", isError: true);
+ }
+ finally
+ {
+ ActivateButton.IsEnabled = true;
+ Busy.IsVisible = false;
+ }
+ }
+
+ private void OnExitClick(object? sender, RoutedEventArgs e)
+ {
+ _completion.TrySetResult(false);
+ Close();
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ // Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
+ _completion.TrySetResult(false);
+ base.OnClosed(e);
+ }
+
+ private void SetStatus(string message, bool isError)
+ {
+ StatusText.Text = message;
+ StatusText.IsVisible = true;
+ StatusText.Foreground = isError
+ ? new SolidColorBrush(Color.FromRgb(205, 92, 92))
+ : new SolidColorBrush(Color.FromRgb(150, 150, 150));
+ }
+}
diff --git a/src/Predictalytics.Shell/Views/MainWindow.axaml b/src/Predictalytics.Shell/Views/MainWindow.axaml
new file mode 100644
index 0000000..701ddf0
--- /dev/null
+++ b/src/Predictalytics.Shell/Views/MainWindow.axaml
@@ -0,0 +1,218 @@
+
+
+
+
+ Cascadia Code,DejaVu Sans Mono,Liberation Mono,Consolas,monospace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Predictalytics.Shell/Views/MainWindow.axaml.cs b/src/Predictalytics.Shell/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..c710492
--- /dev/null
+++ b/src/Predictalytics.Shell/Views/MainWindow.axaml.cs
@@ -0,0 +1,56 @@
+using System.Collections.Specialized;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Predictalytics.Shell.ViewModels;
+
+namespace Predictalytics.Shell.Views;
+
+public partial class MainWindow : Window
+{
+ private MainWindowViewModel? _vm;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ public MainWindow(MainWindowViewModel viewModel) : this()
+ {
+ _vm = viewModel;
+ DataContext = viewModel;
+
+ // Dialoge kann nur das Fenster zeigen — das ViewModel bekommt sie als Rueckrufe.
+ viewModel.ShowInfo = (title, message) => Dialogs.ShowInfoAsync(this, title, message);
+ viewModel.ShowError = (title, message) => Dialogs.ShowErrorAsync(this, title, message);
+ viewModel.ShowConfirm = (title, message) => Dialogs.ShowConfirmAsync(this, title, message);
+
+ // Terminal mitlaufen lassen.
+ viewModel.LogLines.CollectionChanged += OnLogLinesChanged;
+ }
+
+ private void OnLogLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action != NotifyCollectionChangedAction.Add) return;
+
+ // Nach dem Layout ans Ende scrollen, sonst existiert der Container noch nicht.
+ Dispatcher.UIThread.Post(() =>
+ {
+ var list = this.FindControl("LogList");
+ if (list?.ItemCount > 0) list.ScrollIntoView(list.ItemCount - 1);
+ }, DispatcherPriority.Background);
+ }
+
+ private void OnExitClick(object? sender, RoutedEventArgs e) => Close();
+
+ protected override void OnClosed(EventArgs e)
+ {
+ if (_vm != null)
+ {
+ _vm.LogLines.CollectionChanged -= OnLogLinesChanged;
+ _vm.Shutdown();
+ _vm = null;
+ }
+ base.OnClosed(e);
+ }
+}
diff --git a/src/Predictalytics.Shell/app.manifest b/src/Predictalytics.Shell/app.manifest
new file mode 100644
index 0000000..be47291
--- /dev/null
+++ b/src/Predictalytics.Shell/app.manifest
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ PerMonitorV2
+
+
+