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 @@ + + + + + + + + + + + + + + +