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}");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user