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:
@@ -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
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user