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:
Richard
2026-08-06 20:55:47 +02:00
co-authored by Claude Opus 5
parent 0b8728b25f
commit 4ba8149e64
9 changed files with 873 additions and 1 deletions
@@ -0,0 +1,124 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Controls.SettingsEditor">
<!--
Ersatz für das WinForms-PropertyGrid. Aufbau nach Vorgabe:
Kategorie-Überschrift (fett), darunter je Feld eine Zeile aus Beschriftung links und
Steuerelement rechts; ein etwaiger Erklärungstext steht klein unter dem Feld.
Vollständig deklarativ: WELCHE Felder es gibt, liefert das Datenmodell
(SettingsModelBuilder liest Category/DisplayName/Description/Browsable vom Einstellungs-
objekt). WIE ein Feld aussieht, steht hier — je Feldtyp ein DataTemplate. Damit bleibt die
Layout-Regel gewahrt und alle Einstellungsfenster sehen automatisch gleich aus.
-->
<UserControl.Resources>
<!-- Einheitliche Beschriftungsspalte: sorgt für die saubere Flucht aller Felder. -->
<x:Double x:Key="LabelWidth">210</x:Double>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Margin" Value="0,16,0,8" />
</Style>
<Style Selector="TextBlock.fieldLabel">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,12,0" />
</Style>
<Style Selector="TextBlock.fieldHint">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#777777" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="0,2,0,0" />
</Style>
<Style Selector="Grid.fieldRow">
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
</UserControl.Styles>
<ScrollViewer>
<ItemsControl Name="sections" Margin="16,0,16,16">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsSection">
<StackPanel>
<TextBlock Classes="sectionTitle" Text="{Binding Title}" />
<ItemsControl ItemsSource="{Binding Fields}">
<ItemsControl.DataTemplates>
<!-- Freitext -->
<DataTemplate DataType="vm:TextSettingsField">
<Grid Classes="fieldRow" ColumnDefinitions="210,*">
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding Value, Mode=TwoWay}" IsEnabled="{Binding IsEditable}" />
<TextBlock Classes="fieldHint" Text="{Binding Description}"
IsVisible="{Binding HasDescription}" />
</StackPanel>
</Grid>
</DataTemplate>
<!-- Ganzzahl -->
<DataTemplate DataType="vm:IntSettingsField">
<Grid Classes="fieldRow" ColumnDefinitions="210,*">
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<TextBox Text="{Binding Value, Mode=TwoWay}" IsEnabled="{Binding IsEditable}"
MaxWidth="160" HorizontalAlignment="Left" />
<TextBlock Classes="fieldHint" Text="{Binding Description}"
IsVisible="{Binding HasDescription}" />
</StackPanel>
</Grid>
</DataTemplate>
<!-- Ja/Nein -->
<DataTemplate DataType="vm:BoolSettingsField">
<Grid Classes="fieldRow" ColumnDefinitions="210,*">
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<CheckBox IsChecked="{Binding Value, Mode=TwoWay}" IsEnabled="{Binding IsEditable}" />
<TextBlock Classes="fieldHint" Text="{Binding Description}"
IsVisible="{Binding HasDescription}" />
</StackPanel>
</Grid>
</DataTemplate>
<!-- Auswahl -->
<DataTemplate DataType="vm:ChoiceSettingsField">
<Grid Classes="fieldRow" ColumnDefinitions="210,*">
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<ComboBox ItemsSource="{Binding Options}"
SelectedItem="{Binding Value, Mode=TwoWay}"
IsEnabled="{Binding IsEditable}"
HorizontalAlignment="Stretch" />
<TextBlock Classes="fieldHint" Text="{Binding Description}"
IsVisible="{Binding HasDescription}" />
</StackPanel>
</Grid>
</DataTemplate>
<!-- Reine Anzeige -->
<DataTemplate DataType="vm:ReadOnlySettingsField">
<Grid Classes="fieldRow" ColumnDefinitions="210,*">
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<SelectableTextBlock Text="{Binding Value}" VerticalAlignment="Center"
Foreground="#444444" />
<TextBlock Classes="fieldHint" Text="{Binding Description}"
IsVisible="{Binding HasDescription}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PolyTrader.App.Avalonia.ViewModels;
namespace PolyTrader.App.Avalonia.Controls
{
/// <summary>
/// Zeigt ein beliebiges Einstellungsobjekt als kategorisiertes Formular. Ersetzt das
/// WinForms-PropertyGrid an allen vier Stellen (Server-Settings, Account-Einstellungen,
/// Master-Trader, ResolutionFarming) ein Aufruf je Fenster, kein Feld-Code.
///
/// Änderungen wirken direkt auf dem übergebenen Objekt; das Speichern (Datei/DB) bleibt
/// Sache des jeweiligen Fensters genau wie zuvor beim PropertyGrid.
/// </summary>
public partial class SettingsEditor : UserControl
{
public SettingsEditor() => AvaloniaXamlLoader.Load(this);
/// <summary>Setzt das anzuzeigende Objekt (oder <c>null</c>, um zu leeren).</summary>
public void Show(object? target)
{
var list = this.FindControl<ItemsControl>("sections")!;
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target);
}
}
}
+25
View File
@@ -210,6 +210,31 @@ namespace PolyTrader.App.Avalonia
Console.WriteLine($"[FEHLER] ShutdownConfirmWindow: {ex.GetType().Name}: {ex.Message}");
}
// Der Einstellungs-Editor wird aus den Attributen des Modells aufgebaut. Ein Fenster kann
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen -
// deshalb hier gegen die tatsaechliche Feldzahl pruefen.
try
{
var sections = ViewModels.SettingsModelBuilder.Build(new ServerSettings());
int fieldCount = sections.Sum(x => x.Fields.Count);
if (sections.Count == 0 || fieldCount == 0)
{
failures++;
Console.WriteLine("[FEHLER] Einstellungs-Editor: keine Felder aus ServerSettings ermittelt " +
"(Category-/DisplayName-Attribute verloren?).");
}
else
{
Console.WriteLine($"[OK] Einstellungs-Editor: {sections.Count} Abschnitte, {fieldCount} Felder " +
$"({string.Join(", ", sections.Select(x => x.Title))})");
}
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor: {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures == 0 ? 0 : 1;
}
+16 -1
View File
@@ -35,6 +35,21 @@ namespace PolyTrader.App.Avalonia.Shell
ServerSettingsPath)
});
host.RegisterView(new ModuleView
{
Id = "core.settings",
Title = "Server Settings",
Group = "Core",
Order = 20,
CreateView = () => new Views.SettingsWindow(
host,
services.GetRequiredService<PolyTrader.Core.Persistence.IAccountRepository>(),
services.GetRequiredService<TradingState>(),
services.GetRequiredService<TerminalLogger>(),
services.GetService<WatchdogHeartbeatService>(),
services.GetService<MullvadVpnService>())
});
host.RegisterView(new ModuleView
{
Id = "core.jobs",
@@ -44,7 +59,7 @@ namespace PolyTrader.App.Avalonia.Shell
CreateView = () => new Views.JobsWindow(host, services.GetRequiredService<JobManager>())
});
// TODO Portierung: core.settings (Order 20) und core.terminal (Order 40) folgen.
// TODO Portierung: core.terminal (Order 40) folgt.
// Aufbau siehe docs/UI-SPEZIFIKATION-WinForms.md, Originalcode im Git-Tag winforms-final.
}
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace PolyTrader.App.Avalonia.ViewModels
{
/// <summary>Ein Eingabefeld der Einstellungen. Schreibt Änderungen direkt in das Zielobjekt.</summary>
public abstract class SettingsField : INotifyPropertyChanged
{
protected readonly object Target;
protected readonly PropertyInfo Property;
protected SettingsField(object target, PropertyInfo property, string label, string? description)
{
Target = target;
Property = property;
Label = label;
Description = description ?? string.Empty;
}
public string Label { get; }
/// <summary>Erklärungstext aus dem <see cref="DescriptionAttribute"/> erscheint klein unter dem Feld.</summary>
public string Description { get; }
public bool HasDescription => Description.Length > 0;
/// <summary>Falsch bei Eigenschaften ohne Setter (reine Statusanzeigen).</summary>
public bool IsEditable => Property.CanWrite;
protected void Raise([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
public event PropertyChangedEventHandler? PropertyChanged;
}
/// <summary>Freitext (string).</summary>
public sealed class TextSettingsField : SettingsField
{
public TextSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get => Property.GetValue(Target) as string ?? string.Empty;
set { if (IsEditable) { Property.SetValue(Target, value ?? string.Empty); Raise(); } }
}
}
/// <summary>Ja/Nein (bool) als Kontrollkästchen.</summary>
public sealed class BoolSettingsField : SettingsField
{
public BoolSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public bool Value
{
get => Property.GetValue(Target) is true;
set { if (IsEditable) { Property.SetValue(Target, value); Raise(); } }
}
}
/// <summary>
/// Ganzzahl. Bewusst als Text mit invarianter Umwandlung: eine ungültige Eingabe lässt den
/// bisherigen Wert stehen, statt still eine 0 zu schreiben.
/// </summary>
public sealed class IntSettingsField : SettingsField
{
public IntSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get => Convert.ToInt32(Property.GetValue(Target) ?? 0).ToString(CultureInfo.InvariantCulture);
set
{
if (!IsEditable) return;
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
Property.SetValue(Target, parsed);
Raise();
}
}
}
/// <summary>Auswahl aus festen Werten (Enum) als Aufklappliste.</summary>
public sealed class ChoiceSettingsField : SettingsField
{
public ChoiceSettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d)
{
Options = Enum.GetNames(Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType);
}
public IReadOnlyList<string> Options { get; }
public string? Value
{
get => Property.GetValue(Target)?.ToString();
set
{
if (!IsEditable || value == null) return;
Property.SetValue(Target, Enum.Parse(
Nullable.GetUnderlyingType(Property.PropertyType) ?? Property.PropertyType, value));
Raise();
}
}
}
/// <summary>Nur-Lese-Anzeige (Eigenschaft ohne Setter, oder nicht editierbarer Typ).</summary>
public sealed class ReadOnlySettingsField : SettingsField
{
public ReadOnlySettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { }
public string Value
{
get
{
object? raw = Property.GetValue(Target);
if (raw is System.Collections.IEnumerable seq and not string)
return string.Join(", ", seq.Cast<object?>().Select(o => o?.ToString()));
return raw?.ToString() ?? string.Empty;
}
}
}
/// <summary>Eine Kategorie mit ihren Feldern entspricht einer Überschrift im Dialog.</summary>
public sealed class SettingsSection
{
public string Title { get; init; } = string.Empty;
public IReadOnlyList<SettingsField> Fields { get; init; } = Array.Empty<SettingsField>();
}
/// <summary>
/// Baut aus einem Einstellungsobjekt die Abschnitte für den <c>SettingsEditor</c>.
///
/// <para>Nutzt die Attribute, die für das frühere <c>PropertyGrid</c> ohnehin schon gepflegt
/// wurden: <see cref="CategoryAttribute"/> gruppiert, <see cref="DisplayNameAttribute"/> liefert
/// die Beschriftung, <see cref="DescriptionAttribute"/> den Hinweistext unter dem Feld, und
/// <c>[Browsable(false)]</c> blendet aus (z.B. Watchdog-Token und Lizenzschlüssel die gehören
/// nicht offen in die Oberfläche).</para>
///
/// <para>Damit ist der Ersatz des PropertyGrid keine Fleißarbeit je Feld: die Beschreibung der
/// Felder steht weiterhin am Modell, nur die Darstellung ist jetzt eine eigene, gestaltbare
/// Oberfläche statt einer generischen Reflection-Liste.</para>
/// </summary>
public static class SettingsModelBuilder
{
public static IReadOnlyList<SettingsSection> Build(object target)
{
var fields = target.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
.Select(p => new
{
Property = p,
Category = p.GetCustomAttribute<CategoryAttribute>()?.Category ?? "Allgemein",
Label = p.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? p.Name,
Description = p.GetCustomAttribute<DescriptionAttribute>()?.Description
})
.ToList();
return fields
.GroupBy(f => f.Category)
.Select(g => new SettingsSection
{
Title = g.Key,
Fields = g.Select(f => Create(target, f.Property, f.Label, f.Description)).ToList()
})
.ToList();
}
private static SettingsField Create(object target, PropertyInfo p, string label, string? description)
{
Type t = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType;
if (!p.CanWrite) return new ReadOnlySettingsField(target, p, label, description);
if (t.IsEnum) return new ChoiceSettingsField(target, p, label, description);
if (t == typeof(bool)) return new BoolSettingsField(target, p, label, description);
if (t == typeof(int) || t == typeof(long)) return new IntSettingsField(target, p, label, description);
if (t == typeof(string)) return new TextSettingsField(target, p, label, description);
// Listen u.a. werden angezeigt, aber nicht hier bearbeitet (z.B. DisabledModules
// die pflegt der Modul-Tab des Dashboards).
return new ReadOnlySettingsField(target, p, label, description);
}
}
}
@@ -0,0 +1,26 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PolyTrader.App.Avalonia.Views.DialogWindow"
Width="520" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
CanResize="False"
ShowInTaskbar="False">
<!--
Ein Dialog für alle drei Fälle, die die WinForms-Oberfläche über MessageBox/Prompt löste:
Hinweis, Ja/Nein-Rückfrage und (maskierte) Eingabe. Avalonia bringt keine MessageBox mit;
ein eigener Dialog ist der ehrlichere Weg als eine Fremdbibliothek für drei Anwendungsfälle.
-->
<StackPanel Margin="20" Spacing="12">
<TextBlock Name="lblTitle" FontSize="15" FontWeight="SemiBold" />
<TextBlock Name="lblMessage" TextWrapping="Wrap" />
<TextBox Name="txtInput" IsVisible="False" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Name="btnCancel" Content="Abbrechen" Padding="16,6" IsCancel="True" />
<Button Name="btnOk" Content="OK" Padding="16,6" IsDefault="True" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,66 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// Kleiner Allzweck-Dialog: Hinweis, Ja/Nein-Rückfrage und (maskierte) Eingabe.
/// Ersetzt die MessageBox-/Prompt-Aufrufe der WinForms-Oberfläche.
/// </summary>
public partial class DialogWindow : Window
{
public DialogWindow() => AvaloniaXamlLoader.Load(this);
private void Setup(string title, string message, bool withInput, bool masked, string okText, bool withCancel)
{
Title = title;
this.FindControl<TextBlock>("lblTitle")!.Text = title;
this.FindControl<TextBlock>("lblMessage")!.Text = message;
var input = this.FindControl<TextBox>("txtInput")!;
input.IsVisible = withInput;
if (withInput)
{
input.PasswordChar = masked ? '•' : '\0';
input.Text = string.Empty;
}
var ok = this.FindControl<Button>("btnOk")!;
var cancel = this.FindControl<Button>("btnCancel")!;
ok.Content = okText;
cancel.IsVisible = withCancel;
ok.Click += (_, _) => Close(true);
cancel.Click += (_, _) => Close(false);
}
/// <summary>Reiner Hinweis mit OK.</summary>
public static async Task Info(Window owner, string title, string message)
{
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: false, masked: false, okText: "OK", withCancel: false);
await dlg.ShowDialog(owner);
}
/// <summary>Ja/Nein-Rückfrage. Liefert <c>true</c> bei Bestätigung.</summary>
public static async Task<bool> Confirm(Window owner, string title, string message, string okText = "Ja")
{
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: false, masked: false, okText: okText, withCancel: true);
return await dlg.ShowDialog<bool>(owner);
}
/// <summary>
/// Eingabe. Liefert <c>null</c> bei Abbruch leerer String bedeutet bewusst „Wert entfernen"
/// (dieselbe Unterscheidung wie in der bisherigen Oberfläche).
/// </summary>
public static async Task<string?> Prompt(Window owner, string title, string message, bool masked = true)
{
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: true, masked: masked, okText: "OK", withCancel: true);
bool ok = await dlg.ShowDialog<bool>(owner);
return ok ? dlg.FindControl<TextBox>("txtInput")!.Text ?? string.Empty : null;
}
}
}
@@ -0,0 +1,75 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:models="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.SettingsWindow"
Title="Server Settings"
Width="1185" Height="820">
<!--
Einstellungen. Ersetzt die beiden PropertyGrids der WinForms-Fassung durch den
kategorisierten SettingsEditor (Überschrift, Beschriftung links, Feld rechts,
Erklärung klein darunter).
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblStatus" Text="Bereit." />
</Border>
<TabControl>
<TabItem Header="Allgemeine Einstellungen">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button Name="btnSave" Content="Speichern" />
<Button Name="btnReload" Content="Neu laden" />
<Separator Margin="6,0" />
<Button Name="btnGenMasterKey" Content="Master-Key erzeugen"
ToolTip.Tip="Erzeugt einen zufälligen AES-Master-Key (nur wenn noch keiner existiert)." />
<Button Name="btnOpenRouterKey" Content="OpenRouter-Key setzen …"
ToolTip.Tip="Speichert den OpenRouter-API-Key für den Supervisor (gitignorierte Datei openrouter.key)." />
<Button Name="btnWatchdogToken" Content="Watchdog-Token setzen …"
ToolTip.Tip="Speichert den Watchdog-Agent-Token (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt)." />
<Button Name="btnTestWatchdog" Content="Test-Heartbeat senden"
ToolTip.Tip="Sendet sofort einen Heartbeat an den konfigurierten Watchdog-Server." />
</StackPanel>
</Border>
<controls:SettingsEditor Name="editorServer" />
</DockPanel>
</TabItem>
<TabItem Header="Polymarket Accounts">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button Name="btnAccNew" Content="Neuer Account" />
<Button Name="btnAccDelete" Content="Löschen" />
<Button Name="btnAccSave" Content="Account speichern" />
</StackPanel>
</Border>
<Grid ColumnDefinitions="380,4,*">
<DataGrid Name="gridAccounts" Grid.Column="0" x:DataType="models:AccountState">
<DataGrid.Columns>
<DataGridTextColumn Header="#" Width="50" Binding="{Binding AccountId}" />
<DataGridTextColumn Header="Name" Width="*" Binding="{Binding Name}" />
<DataGridCheckBoxColumn Header="Demo" Width="60" Binding="{Binding IsDemo}" />
<DataGridCheckBoxColumn Header="Aktiv" Width="60" Binding="{Binding IsActive}" />
</DataGrid.Columns>
</DataGrid>
<GridSplitter Grid.Column="1" Background="#DDDDDD" />
<controls:SettingsEditor Name="editorAccount" Grid.Column="2" />
</Grid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -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;
}
}
}
}