Settings nach Avalonia - PropertyGrid durch kategorisierten Editor ersetzt
Der PropertyGrid-Ersatz ist EIN wiederverwendbares Steuerelement statt Handarbeit je Feld: Controls/SettingsEditor zeigt ein beliebiges Einstellungsobjekt nach Richards Vorlage - Kategorie-Ueberschrift, Beschriftung links, Feld rechts, Erklaerung klein darunter. - SettingsModelBuilder liest die Attribute, die fuers PropertyGrid ohnehin gepflegt waren: [Category] gruppiert, [DisplayName] beschriftet, [Description] wird zum Hinweistext, [Browsable(false)] blendet aus (Watchdog-Token, Lizenzschluessel bleiben unsichtbar). Ergebnis fuer ServerSettings: 6 Abschnitte, 15 Felder - ohne eine Zeile Feld-Code. - Layout-Regel gewahrt: WELCHE Felder es gibt, kommt als Daten; WIE ein Feld aussieht, steht deklarativ als DataTemplate je Feldtyp (Text/Zahl/Ja-Nein/Auswahl/Nur-Lese). - Damit sind auch die drei restlichen PropertyGrid-Stellen (Account-Einstellungen, Master-Trader, ResolutionFarming) mit je einem Aufruf erledigt. SettingsWindow: Server-Settings + Polymarket-Accounts, Master-Key erzeugen, OpenRouter-Key/Watchdog-Token setzen, Test-Heartbeat. Rueckmeldungen laufen ueber die Statuszeile statt ueber Dialoge - nur echte Entscheidungen bekommen einen Dialog (neu: Views/DialogWindow fuer Hinweis/Rueckfrage/maskierte Eingabe, Avalonia hat keine MessageBox). Nebenbei einen offenen Punkt der Linux-Analyse erledigt: Schluesseldateien (master.key, openrouter.key) werden jetzt per File.SetUnixFileMode auf 600 gesetzt. Auf Linux legt File.WriteAllText sonst mit ueblicher umask 644 an - weltweit lesbar. Lizenzdialog bewusst NICHT portiert: haengt am alten LicenseLabrador-SDK, das mit der Deploymentcenter-Anbindung (P3c) ohnehin ersetzt wird - waere Wegwerfarbeit. Smoke-UI prueft jetzt zusaetzlich die Feldzahl des Editors: ein Fenster kann fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen. Verifiziert: Solution baut, 442 Tests gruen, --smoke-ui gruen (5 Fenster + Editor-Pruefung), Linux-Publish laeuft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PolyTrader.App.Avalonia.Controls;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTraderSharp;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstellungen: allgemeine Server-Settings und die Polymarket-Accounts. Beide Bereiche nutzen
|
||||
/// den <see cref="SettingsEditor"/> anstelle der früheren PropertyGrids.
|
||||
/// Layout vollständig in SettingsWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
private const string SettingsPath = "server_settings.xml";
|
||||
|
||||
private readonly ObservableCollection<AccountState> _accounts = new();
|
||||
|
||||
private ServerSettings _settings = new();
|
||||
private IAccountRepository? _accountRepo;
|
||||
private TradingState? _state;
|
||||
private WatchdogHeartbeatService? _watchdog;
|
||||
private MullvadVpnService? _vpn;
|
||||
private TerminalLogger? _logger;
|
||||
|
||||
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
|
||||
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
|
||||
MullvadVpnService? vpn = null) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.settings", this);
|
||||
|
||||
_accountRepo = accountRepo;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
_watchdog = watchdog;
|
||||
_vpn = vpn;
|
||||
|
||||
var grid = this.FindControl<DataGrid>("gridAccounts")!;
|
||||
grid.ItemsSource = _accounts;
|
||||
grid.SelectionChanged += (_, _) =>
|
||||
this.FindControl<SettingsEditor>("editorAccount")!.Show(grid.SelectedItem);
|
||||
|
||||
this.FindControl<Button>("btnSave")!.Click += (_, _) => SaveServerSettings();
|
||||
this.FindControl<Button>("btnReload")!.Click += (_, _) => ReloadServerSettings();
|
||||
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await GenerateMasterKeyAsync();
|
||||
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
|
||||
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
|
||||
this.FindControl<Button>("btnTestWatchdog")!.Click += async (_, _) => await TestWatchdogAsync();
|
||||
|
||||
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
|
||||
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
|
||||
this.FindControl<Button>("btnAccSave")!.Click += (_, _) => SaveSelectedAccount();
|
||||
|
||||
ReloadServerSettings();
|
||||
LoadAccounts();
|
||||
UpdateMasterKeyButtonState();
|
||||
}
|
||||
|
||||
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
|
||||
|
||||
// ===== Server-Settings =====
|
||||
|
||||
private void ReloadServerSettings()
|
||||
{
|
||||
_settings = ServerSettings.Load(SettingsPath);
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
Status("Einstellungen geladen.");
|
||||
}
|
||||
|
||||
private void SaveServerSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
_settings.Save(SettingsPath);
|
||||
_vpn?.ReloadSettings();
|
||||
_watchdog?.ReloadSettings();
|
||||
_logger?.Info("Server-Einstellungen gespeichert und Dienste neu geladen.");
|
||||
Status($"Gespeichert um {DateTime.Now:HH:mm:ss}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Status($"Speichern fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Polymarket Accounts =====
|
||||
|
||||
private void LoadAccounts()
|
||||
{
|
||||
if (_state == null) return;
|
||||
_accounts.Clear();
|
||||
foreach (var a in _state.Accounts.Values.OrderBy(a => a.AccountId)) _accounts.Add(a);
|
||||
|
||||
var grid = this.FindControl<DataGrid>("gridAccounts")!;
|
||||
if (_accounts.Count > 0) grid.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void AddAccount()
|
||||
{
|
||||
if (_state == null || _accountRepo == null) return;
|
||||
|
||||
int newId = _state.Accounts.Count > 0 ? _state.Accounts.Keys.Max() + 1 : 1;
|
||||
var acc = new AccountState { AccountId = newId, Name = "Neuer Account" };
|
||||
|
||||
_state.Accounts[acc.AccountId] = acc;
|
||||
_accountRepo.Upsert(acc);
|
||||
_accounts.Add(acc);
|
||||
|
||||
this.FindControl<DataGrid>("gridAccounts")!.SelectedItem = acc;
|
||||
Status($"Account #{newId} angelegt.");
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task DeleteAccountAsync()
|
||||
{
|
||||
if (_state == null || _accountRepo == null) return;
|
||||
if (this.FindControl<DataGrid>("gridAccounts")!.SelectedItem is not AccountState acc) return;
|
||||
|
||||
bool ok = await DialogWindow.Confirm(this, "Löschen bestätigen",
|
||||
$"Account „{acc.Name}\" (ID {acc.AccountId}) wirklich löschen?", "Löschen");
|
||||
if (!ok) return;
|
||||
|
||||
_state.Accounts.TryRemove(acc.AccountId, out _);
|
||||
_accountRepo.Delete(acc.AccountId);
|
||||
_accounts.Remove(acc);
|
||||
this.FindControl<SettingsEditor>("editorAccount")!.Show(null);
|
||||
Status($"Account #{acc.AccountId} gelöscht.");
|
||||
}
|
||||
|
||||
private void SaveSelectedAccount()
|
||||
{
|
||||
if (this.FindControl<DataGrid>("gridAccounts")!.SelectedItem is not AccountState acc) return;
|
||||
_accountRepo?.Upsert(acc);
|
||||
_state?.Accounts.AddOrUpdate(acc.AccountId, acc, (_, _) => acc);
|
||||
Status($"Account #{acc.AccountId} gespeichert um {DateTime.Now:HH:mm:ss}.");
|
||||
}
|
||||
|
||||
// ===== Master-Key (at-rest-Verschlüsselung) =====
|
||||
|
||||
private static string MasterKeyFilePath => Path.Combine(AppContext.BaseDirectory, "master.key");
|
||||
|
||||
private static bool MasterKeyExists() =>
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"))
|
||||
|| File.Exists(MasterKeyFilePath);
|
||||
|
||||
/// <summary>Schaltfläche nur aktiv, solange KEIN Master-Key existiert (Überschreiben = Lockout-Gefahr).</summary>
|
||||
private void UpdateMasterKeyButtonState() =>
|
||||
this.FindControl<Button>("btnGenMasterKey")!.IsEnabled = !MasterKeyExists();
|
||||
|
||||
private async System.Threading.Tasks.Task GenerateMasterKeyAsync()
|
||||
{
|
||||
// Sicherheitsnetz gegen Race/Doppelklick: einen bestehenden Key NIEMALS überschreiben.
|
||||
if (MasterKeyExists())
|
||||
{
|
||||
await DialogWindow.Info(this, "Master-Key vorhanden",
|
||||
"Es existiert bereits ein Master-Key – Erzeugung abgebrochen. Ein Überschreiben würde den " +
|
||||
"Zugriff auf bereits verschlüsselte Wallet-Keys unwiederbringlich zerstören.");
|
||||
UpdateMasterKeyButtonState();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] keyBytes = RandomNumberGenerator.GetBytes(32); // 256-Bit-Schlüssel
|
||||
File.WriteAllText(MasterKeyFilePath, Convert.ToBase64String(keyBytes));
|
||||
RestrictToOwner(MasterKeyFilePath);
|
||||
|
||||
_logger?.Info("🔐 Master-Key erzeugt und in master.key gespeichert. At-rest-Verschlüsselung wird beim nächsten Start aktiv.");
|
||||
await DialogWindow.Info(this, "Master-Key erzeugt",
|
||||
"Ein zufälliger 32-Byte-Master-Key wurde erzeugt und in der Datei „master.key\" " +
|
||||
"(App-Ordner, gitignored) gespeichert.\n\n" +
|
||||
"WICHTIG:\n" +
|
||||
"• Sichere diese Datei SOFORT separat (Passwort-Manager / Offline-Backup).\n" +
|
||||
"• Master-Key-Verlust = KEIN Zugriff mehr auf die verschlüsselten Wallet-Keys!\n" +
|
||||
"• Die Verschlüsselung der Account-Credentials wird beim nächsten Programmstart aktiv.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DialogWindow.Info(this, "Fehler", $"Fehler beim Erzeugen des Master-Keys: {ex.Message}");
|
||||
}
|
||||
|
||||
UpdateMasterKeyButtonState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Beschränkt eine Schlüsseldatei auf den Besitzer. Auf Linux legt <see cref="File.WriteAllText"/>
|
||||
/// mit der üblichen umask sonst <c>644</c> an – die Datei wäre für alle lesbar. Unter Windows
|
||||
/// greift die NTFS-Vererbung, dort ist nichts zu tun.
|
||||
/// </summary>
|
||||
private void RestrictToOwner(string path)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Warning($"⚠️ Dateirechte für {Path.GetFileName(path)} konnten nicht gesetzt werden: {ex.Message}. " +
|
||||
"Bitte manuell prüfen (chmod 600).");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OpenRouter-API-Key (Supervisor) =====
|
||||
|
||||
private async System.Threading.Tasks.Task SetOpenRouterKeyAsync()
|
||||
{
|
||||
string keyFile = Path.Combine(AppContext.BaseDirectory, "openrouter.key");
|
||||
bool exists = File.Exists(keyFile) ||
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY"));
|
||||
|
||||
string? key = await DialogWindow.Prompt(this, "OpenRouter-API-Key",
|
||||
"OpenRouter-API-Key für den Supervisor eingeben.\n" +
|
||||
"Wird in der gitignorierten Datei „openrouter.key\" gespeichert (leer = entfernen).\n" +
|
||||
(exists ? "Aktuell ist bereits ein Key hinterlegt." : "Aktuell ist KEIN Key gesetzt."));
|
||||
if (key == null) return; // Abbruch
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
if (File.Exists(keyFile)) File.Delete(keyFile);
|
||||
_logger?.Info("OpenRouter-Key aus openrouter.key entfernt.");
|
||||
Status("OpenRouter-Key entfernt.");
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllText(keyFile, key.Trim());
|
||||
RestrictToOwner(keyFile);
|
||||
_logger?.Info("OpenRouter-Key gespeichert (openrouter.key).");
|
||||
Status("OpenRouter-Key gespeichert – wirkt bei der nächsten Supervisor-Anfrage.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des OpenRouter-Keys: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Watchdog =====
|
||||
|
||||
private async System.Threading.Tasks.Task SetWatchdogTokenAsync()
|
||||
{
|
||||
bool exists = !string.IsNullOrWhiteSpace(_settings.WatchdogToken);
|
||||
string? token = await DialogWindow.Prompt(this, "Watchdog Agent-Token",
|
||||
"Agent-Token aus dem Watchdog-Admin eingeben.\n" +
|
||||
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
|
||||
(exists ? "Aktuell ist ein Token gesetzt." : "Aktuell ist KEIN Token gesetzt."));
|
||||
if (token == null) return; // Abbruch
|
||||
|
||||
try
|
||||
{
|
||||
// Protect() gibt ohne Master-Key den Klartext unverändert zurück – kein stiller
|
||||
// Sicherheitsverlust, es wird unten darauf hingewiesen.
|
||||
_settings.WatchdogToken = string.IsNullOrWhiteSpace(token)
|
||||
? string.Empty
|
||||
: SecretProtection.Protect(token.Trim());
|
||||
_settings.Save(SettingsPath);
|
||||
_watchdog?.ReloadSettings();
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
|
||||
if (_settings.WatchdogToken.Length == 0)
|
||||
{
|
||||
_logger?.Info("Watchdog-Agent-Token entfernt.");
|
||||
Status("Watchdog-Token entfernt.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool encrypted = SecretProtection.IsEncrypted(_settings.WatchdogToken);
|
||||
_logger?.Info($"Watchdog-Agent-Token gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
|
||||
Status(encrypted
|
||||
? "Watchdog-Token gespeichert (verschlüsselt) und sofort aktiv."
|
||||
: "Watchdog-Token gespeichert – ACHTUNG: ohne Master-Key im Klartext. Mit „Master-Key erzeugen\" ändern.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des Watchdog-Tokens: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sendet einen Heartbeat mit den GESPEICHERTEN Einstellungen. Ungespeicherte Änderungen
|
||||
/// wirken bewusst nicht – sonst würde ein erfolgreicher Test etwas bestätigen, das im
|
||||
/// laufenden Betrieb gar nicht gilt.
|
||||
/// </summary>
|
||||
private async System.Threading.Tasks.Task TestWatchdogAsync()
|
||||
{
|
||||
if (_watchdog == null) return;
|
||||
var btn = this.FindControl<Button>("btnTestWatchdog")!;
|
||||
btn.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
Status("Sende Test-Heartbeat …");
|
||||
var result = await _watchdog.SendHeartbeatAsync("ok", "Test-Heartbeat aus dem PolyTrader-Settings-Fenster");
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_logger?.Info($"✅ Watchdog-Test-Heartbeat erfolgreich gesendet ({result.Detail}).");
|
||||
Status($"Test-Heartbeat erfolgreich ({result.Detail}). Der Monitor sollte jetzt auf „up\" stehen.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.Warning($"⚠️ Watchdog-Test-Heartbeat fehlgeschlagen: {result.Detail}");
|
||||
await DialogWindow.Info(this, "Watchdog",
|
||||
$"Test-Heartbeat fehlgeschlagen:\n\n{result.Detail}\n\n" +
|
||||
"Bitte URL, Agent-Token und Source prüfen – und die Einstellungen vorher speichern.");
|
||||
Status("Test-Heartbeat fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
btn.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user