- Unabhängige Kestrel-Instanz auf Port 5001 (vollständig getrennt vom Analyse-Webserver) - Admin API Endpunkte ohne Swagger-Exposition - Token-Gatekeeper mit sofortigem Login-Overlay bei unautorisiertem Zugriff - Live-Terminal via SSE-Log-Streaming und Log-Download-Übersicht - Steuerung von Workern, Analyse-Webserver, Wartungsaktionen und Einstellungen - Nahtlose Integration in Avalonia Desktop-Shell und Linux Headless-Modus - Unit-Tests für Admin-Filter, Optionen und Log-Puffer
504 lines
20 KiB
C#
504 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Bindungsziel des Hauptfensters. Haelt den <see cref="PredictalyticsHost"/> und
|
|
/// uebersetzt dessen Zustand in Anzeigewerte.
|
|
/// </summary>
|
|
public sealed partial class MainWindowViewModel : ObservableObject
|
|
{
|
|
/// <summary>Obergrenze der Terminalzeilen. Aeltere werden vorne verworfen.</summary>
|
|
private const int MaxLogLines = 500;
|
|
|
|
private readonly PredictalyticsHost _host;
|
|
private DcHeartbeatService? _heartbeat;
|
|
private double? _lastDbSizeMb;
|
|
|
|
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
|
|
public Func<string, string, Task>? ShowInfo { get; set; }
|
|
public Func<string, string, Task>? ShowError { get; set; }
|
|
public Func<string, string, Task<bool>>? ShowConfirm { get; set; }
|
|
|
|
public PredictalyticsOptions Options { get; }
|
|
|
|
public ObservableCollection<LogLine> 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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Wird nach dem Serilog-Aufbau aufgerufen.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<DcHeartbeatSnapshot> 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;
|
|
}
|
|
|
|
/// <summary>SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat.</summary>
|
|
private async Task<DcCheck?> 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 ───
|
|
|
|
/// <summary>
|
|
/// Asks the UpdateService for a newer release. Silent at startup (log + status bar);
|
|
/// only a critical release interrupts the user.
|
|
/// </summary>
|
|
[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<bool> 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: —";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Oeffnet Pfad oder URL mit der Standardanwendung des Systems.
|
|
/// UseShellExecute funktioniert unter Windows wie unter Linux (dort ueber xdg-open).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Beim Beenden: Monitor abmelden, Worker und Webserver stoppen.</summary>
|
|
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 */ }
|
|
}
|
|
}
|