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 DcHeartbeatService? _heartbeat; private double? _lastDbSizeMb; /// 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 = ""; [ObservableProperty] private string _serverButtonText = "▶ Start Server"; [ObservableProperty] private string _webserverButtonText = "▶ Start Webserver"; [ObservableProperty] private bool _isBusy; /// /// Die Lizenz, auf der dieser Lauf beruht — wird nach der Lizenzschranke gesetzt und /// vom Menuepunkt „Lizenzstatus" gelesen. Das ViewModel entsteht bewusst vorher, damit /// das Terminal schon waehrend der Aktivierung mitschreibt. /// public LicenseSession? License { get; set; } public MainWindowViewModel(PredictalyticsOptions options) { Options = options; _host = new PredictalyticsHost(Options); _host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus); try { var buildDate = new FileInfo(GetType().Assembly.Location).LastWriteTime; BuildVersionText = $"v{DcConfig.AppVersion} — Build: {buildDate:yyyy-MM-dd HH:mm:ss}"; } catch { BuildVersionText = $"v{DcConfig.AppVersion}"; } 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(); RestartHeartbeat(); if (Options.AdminServerEnabled) { _ = Task.Run(async () => { try { await _host.StartAdminServerAsync(); Log.Information("Verwaltungs-WebGUI erreichbar unter {Url}", Options.AdminServerUrl); } catch (Exception ex) { Log.Error(ex, "Verwaltungs-WebGUI konnte nicht gestartet werden"); } }); } if (Options.DcUpdateCheckEnabled) { _ = CheckForUpdatesAsync(silent: true); } } /// 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"; } // ─── Deployment Center: Heartbeat ─── private void RestartHeartbeat() { _heartbeat?.Dispose(); _heartbeat = null; if (!Options.DcHeartbeatEnabled) return; if (string.IsNullOrWhiteSpace(Options.DcToken)) { Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token — bitte in den Einstellungen eintragen."); return; } _heartbeat = new DcHeartbeatService( Options.DcToken, Options.DcSource, Options.DcInstance, Options.DcHeartbeatIntervalSeconds, CollectHeartbeatSnapshotAsync); _heartbeat.Start(); } /// /// Assembles what this app knows about its own health. A heartbeat alone only proves that /// a timer runs — the DB check is what shows whether the app can actually do its work. /// private async Task CollectHeartbeatSnapshotAsync(CancellationToken ct) { var snapshot = new DcHeartbeatSnapshot { Message = _host.WorkersRunning ? "Worker laufen" : "Worker gestoppt" }; // Deliberately no check for "workers stopped": that is a legitimate state chosen by // the operator and would otherwise keep the monitor permanently on warning. snapshot.Metrics["workers_running"] = _host.WorkersRunning ? 1 : 0; snapshot.Metrics["webserver_running"] = _host.WebServerRunning ? 1 : 0; snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1); if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2); var db = await ProbeDatabaseAsync(ct); if (db is not null) snapshot.Checks["db"] = db; return snapshot; } /// SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat. private async Task ProbeDatabaseAsync(CancellationToken ct) { if (string.IsNullOrWhiteSpace(Options.DbName)) return null; var started = System.Diagnostics.Stopwatch.StartNew(); try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(5)); await using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString); await conn.OpenAsync(timeout.Token); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT 1"; await cmd.ExecuteScalarAsync(timeout.Token); return new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds); } catch (Exception ex) { return new DcCheck(false, ex.Message); } } // ─── Deployment Center: Updates und Lizenz ─── /// /// Asks the UpdateService for a newer release. Silent at startup (log + status bar); /// only a critical release interrupts the user. /// [RelayCommand] private Task CheckForUpdatesInteractiveAsync() => CheckForUpdatesAsync(silent: false); private async Task CheckForUpdatesAsync(bool silent) { try { var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel, License?.LicenseKey); // 401 is a licensing statement, not a network hiccup — and it must be caught // before the "up to date" branch: the server answers no version at all, so // silently reporting "aktuell" would hide that updates stopped arriving. if (result.Unauthorized) { Log.Error("⛔ Die Release-Ablage weist die Lizenz zurück — es kommen keine Updates mehr an: {Message}", result.Message); BuildVersionText = $"v{DcConfig.AppVersion} — Update-Zugang abgelehnt"; if (!silent && ShowError != null) await ShowError("Deployment Center", "Die Release-Ablage hat den Lizenzschlüssel abgelehnt (401).\n\n" + $"{result.Message}\n\n" + "Das ist kein Netzwerkproblem: Die Lizenz ist abgelaufen oder widerrufen. " + "Solange das so bleibt, erreichen diese Installation keine Updates."); return; } if (result.Error is not null) { Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message); if (!silent && ShowError != null) await ShowError("Deployment Center", $"Update-Prüfung fehlgeschlagen:\n{result.Message}"); return; } if (!result.UpdateAvailable) { Log.Information("Update-Prüfung: v{Version} ist aktuell (Kanal {Channel}).", DcConfig.AppVersion, Options.DcUpdateChannel); if (!silent && ShowInfo != null) await ShowInfo("Deployment Center", $"Predictalytics v{DcConfig.AppVersion} ist aktuell."); return; } var latest = result.LatestRelease?.Version ?? "?"; Log.Warning("⬆ Update verfügbar: v{Latest} (installiert: v{Current}, Kanal {Channel}){Critical}", latest, DcConfig.AppVersion, Options.DcUpdateChannel, result.IsCritical ? " — KRITISCH" : ""); BuildVersionText = $"v{DcConfig.AppVersion} — Update v{latest} verfügbar"; if (silent && !result.IsCritical) return; var notes = result.LatestRelease?.Changelog; var agentPresent = DcUpdateService.FindUpdateAgent() is not null; var text = $"Neues Release v{latest} verfügbar (installiert: v{DcConfig.AppVersion}).\n" + (result.IsCritical ? "\nDieses Update ist als kritisch markiert.\n" : "") + (string.IsNullOrWhiteSpace(notes) ? "" : $"\n{notes}\n") + (agentPresent ? "\nJetzt installieren? Predictalytics wird dazu beendet." : "\nDer Update-Agent liegt nicht neben der Anwendung — bitte manuell einspielen."); if (!agentPresent) { if (ShowInfo != null) await ShowInfo("Deployment Center", text); return; } if (ShowConfirm == null || !await ShowConfirm("Deployment Center", text)) return; // The agent replaces the running installation, so announce the shutdown first — // otherwise the monitor reports a crash a few minutes later. _heartbeat?.NotifyStopping(); if (!DcUpdateService.LaunchAgent(Options.DcUpdateChannel, License?.LicenseKey) && ShowError != null) { await ShowError("Deployment Center", "Der Update-Agent konnte nicht gestartet werden. Details stehen im Log."); } } catch (Exception ex) { Log.Warning(ex, "Update-Prüfung fehlgeschlagen"); } } [RelayCommand] private async Task ShowLicenseStatusAsync() { if (ShowInfo == null) return; var hardware = LicenseGuard.GetHardwareInfo(); var result = License?.LastResult; var grace = result?.CacheExpiresAt is { } expiresAt && expiresAt > 0 ? $"\nOffline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC" : ""; await ShowInfo("Lizenzstatus", $"Produkt: {DcConfig.ProductSlug}\n" + $"Version: {DcConfig.AppVersion} ({DcConfig.GitCommitShort})\n" + $"Server: {DcConfig.BaseUrl}\n" + $"Hardware-ID: {hardware.HardwareId}\n" + $"Quelle: {hardware.HwidSource}\n\n" + $"Letzte Prüfung: {result?.Status ?? "unbekannt"}" + (result?.IsCached == true ? " (aus Offline-Cache)" : "") + $"\n{result?.Message}{grace}"); } // ─── Befehle ─── [RelayCommand] private void ToggleServer() { if (!_host.WorkersRunning) { Log.Information("🚀 Starte Hintergrund-Worker..."); _host.StartWorkers(); } else { Log.Information("⏹ Stoppe Hintergrund-Worker..."); _host.StopWorkers(); 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(); DcErrorReporter.Configure(Options.DcToken, Options.DcErrorReportingEnabled); RestartHeartbeat(); Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath); UpdateStatus(); } [RelayCommand] private void OpenWebUi() => OpenInShell(Options.WebserverUrl); [RelayCommand] private void OpenAdminWebUi() => OpenInShell(Options.AdminServerUrl); [RelayCommand] private void GenerateAdminToken() { Options.AdminToken = PredictalyticsOptions.GenerateSecureToken(); OnPropertyChanged(nameof(Options)); Log.Information("Neuer Admin-Token generiert. Zum Übernehmen 'Speichern & übernehmen' klicken."); } [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(); _lastDbSizeMb = sizeMb; 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: Monitor abmelden, Worker und Webserver stoppen. public void Shutdown() { _heartbeat?.NotifyStopping(); _heartbeat?.Dispose(); _heartbeat = null; _host.StopWorkers(); try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ } try { _host.StopAdminServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ } } }