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 */ }
}
}