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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -210,6 +210,31 @@ namespace PolyTrader.App.Avalonia
|
|||||||
Console.WriteLine($"[FEHLER] ShutdownConfirmWindow: {ex.GetType().Name}: {ex.Message}");
|
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 ===");
|
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||||||
return failures == 0 ? 0 : 1;
|
return failures == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ namespace PolyTrader.App.Avalonia.Shell
|
|||||||
ServerSettingsPath)
|
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
|
host.RegisterView(new ModuleView
|
||||||
{
|
{
|
||||||
Id = "core.jobs",
|
Id = "core.jobs",
|
||||||
@@ -44,7 +59,7 @@ namespace PolyTrader.App.Avalonia.Shell
|
|||||||
CreateView = () => new Views.JobsWindow(host, services.GetRequiredService<JobManager>())
|
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.
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user