Phase 3: Avalonia-Bedienhuelle fuer Windows und Linux

Neues Projekt Predictalytics.Shell (Avalonia 12.1.1, net10.0) als
plattformuebergreifender Ersatz fuer den WinForms-Host. Setzt auf dem in
Phase 2 extrahierten Predictalytics.Hosting auf.

Oberflaeche:
- MainWindow mit Menue, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab und
  Statusleiste. Die feste Fenstergroesse von 1886x1088 ist aufgeloest.
- Terminal als ListBox ueber ObservableCollection mit Einfaerbung nach
  Loglevel. Ringpuffer statt des bisherigen kompletten Leerens bei 500 Zeilen.
- Einstellungsansicht handgeschrieben als Ersatz fuer den PropertyGrid, den
  es in Avalonia nicht gibt: vier Gruppen, Passwortfelder, mehrzeiliges
  Textfeld fuer die Egress-Kanaele, ComboBox fuer den SSL-Modus. Gespeichert
  wird explizit statt bei jeder Einzeloperation.
- LicenseWindow ersetzt den WinForms-LicenseDialog.
- Dialogs.cs als schlanker Ersatz fuer MessageBox.Show, ohne Drittanbieter.

Headless-Modus (--headless) im selben Binary: fuer Linux-Server ohne
Desktop-Session. Die Argumentauswertung steht vor jeder Avalonia-
Initialisierung, sonst stirbt der Prozess ohne X11 bevor der Schalter greift.
Lizenz ueber PREDICTALYTICS_LICENSE_KEY statt Dialog, SIGTERM und SIGINT
ueber PosixSignalRegistration fuer sauberes systemd-Stop.
Exit-Codes: 0 planmaessig, 2 keine nutzbare Lizenz, 3 Fehler.

Plattformdetails: Schrift-Fallbackkette fuers Terminal, Avalonia.Fonts.Inter
als mitgelieferte UI-Schrift, UTF-8 fuer die Windows-Konsole im
Headless-Modus.

Die Avalonia-Basisklasse wird als global::Avalonia.Application angegeben:
innerhalb von Predictalytics.* loest der kurze Name auf den eigenen
Namespace Predictalytics.Application auf.

Build: 0 Fehler, 0 Warnungen. Tests: 100 bestanden.
Verifiziert: --headless laeuft bis zur Lizenzschranke und beendet sich mit
Exit-Code 2; GUI startet und zeigt das Lizenzfenster. Das Hauptfenster
konnte nicht geprueft werden, weil die hinterlegte Lizenz abgelaufen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-07 10:15:56 +02:00
co-authored by Claude Opus 5
parent 260dff1700
commit 1efd23abe4
18 changed files with 1305 additions and 1 deletions
@@ -0,0 +1,26 @@
using Avalonia.Media;
using Serilog.Events;
namespace Predictalytics.Shell.ViewModels;
/// <summary>Eine Zeile der Terminalanzeige samt Einfaerbung nach Loglevel.</summary>
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))
};
}
@@ -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;
/// <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 CancellationTokenSource? _workerCts;
private WatchdogHeartbeatService? _watchdog;
/// <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 = "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();
}
/// <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();
RestartWatchdog();
}
/// <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";
}
// ─── 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<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();
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: Watchdog abmelden, Worker und Webserver stoppen.</summary>
public void Shutdown()
{
_watchdog?.NotifyStopping();
_watchdog?.Dispose();
_watchdog = null;
_workerCts?.Cancel();
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
}
}
@@ -0,0 +1,19 @@
using MySqlConnector;
namespace Predictalytics.Shell.ViewModels;
/// <summary>Feste Auswahllisten fuer die Einstellungsansicht.</summary>
public static class OptionSources
{
/// <summary>
/// Die praktisch relevanten SSL-Modi. Die uebrigen Werte des Enums
/// (VerifyCA, VerifyFull) erfordern hinterlegte Zertifikate und wuerden
/// hier nur zu Fehlkonfiguration einladen.
/// </summary>
public static MySqlSslMode[] SslModes { get; } =
[
MySqlSslMode.None,
MySqlSslMode.Preferred,
MySqlSslMode.Required
];
}