L3: Avalonia-Shell und Core-Ansichten - die Oberflaeche laeuft auf Linux
Neues Projekt IBKRTrader.App.Avalonia (net10.0, plattformneutral) mit Launcher, Fenster-Menue, Beenden-Abfrage und den vier Core-Ansichten Dashboard, Workers, Logs und Settings. Nutzt denselben AppHostBuilder wie der Daemon; ShellServices ergaenzt nur, was ohne Oberflaeche nicht existiert. Versionen bewusst auf der 11er-Linie: Avalonia 11.3.19, DataGrid 11.3.13. LiveCharts2 2.0.5 ist gegen Avalonia 11 gebaut und bricht unter 12 (Gestures.PinchEvent gibt es dort nicht mehr). Diagramme kommen mit den neuen Modulen - bis LiveCharts2 Avalonia 12 unterstuetzt, darf hier nicht angehoben werden. Erfahrung aus PolytraderSharp, im csproj vermerkt. Ersatz fuer WinForms-Bausteine ohne Gegenstueck: - PropertyGrid -> SettingsModelBuilder erzeugt die Maske aus den bereits vorhandenen Category-/DisplayName-/Description-Attributen von AppSettings. Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die Oberflaeche anfasst - genau der Vorteil des PropertyGrid. Kennwortfelder (DB-Passwort, Flex-Token) werden verdeckt. Gemessen: 11 Abschnitte, 41 Felder. - MessageBox.Show -> ShutdownConfirmWindow (Avalonia bringt keinen Meldungsdialog mit). Schliessen ueber das X zaehlt als Abbruch, damit ein versehentlicher Klick nie den Handelsbetrieb stoppt. - RichTextBox mit SelectionColor -> eingefaerbte Elemente je Logzeile, mit Filter, Auto-Scroll und Zeilenbegrenzung (im Dauerbetrieb waere die Liste sonst unbegrenzt gewachsen). - ToolStrip/StatusStrip -> zentrale Stilklassen in App.axaml (toolbar, statusbar, kpi, section). Die neuen Module setzen darauf auf. Gemeinsame Bausteine bewusst jetzt schon zentral, weil die neuen Module direkt in Avalonia entwickelt werden sollen. Kompilierte Bindings sind aktiv (x:DataType je Datenkontext) - Tippfehler in Bindings fallen damit beim Kompilieren auf statt erst zur Laufzeit. Dafuer brauchte es benannte Record-Zeilentypen statt der anonymen Typen, die DataGridView.DataSource frueher bekommen hat. --smoke-ui laeuft jetzt ueber SetupWithoutStarting, also OHNE Anzeigegeraet und ohne laufende Dienste. Das war mit WinForms nicht moeglich und macht die Konstruktionspruefung erstmals CI-tauglich. Der Host wird dabei bewusst nicht gestartet, sonst liefen Worker und Broker-Verbindungen gegen die echten Endpunkte an. AVLN3001 unterdrueckt: die Fenster haben absichtlich keinen parameterlosen Konstruktor. Einer wuerde sie ohne ihre Dienste konstruierbar machen und genau den Fehler verdecken, den die Konstruktionspruefung finden soll. Verifiziert: Build 0 Fehler/0 Warnungen, 193 Tests gruen, Avalonia-Smoke-UI konstruiert alle 6 Fenster, publish -r linux-x64 liefert 32 MB mit ELF-Launcher samt libSkiaSharp.so/libHarfBuzzSharp.so und ohne Windows-Abhaengigkeiten. Die WinForms-Shell ist unveraendert lauffaehig. Offen fuer L4: die drei Modul-Fenster (ModuleViews ist noch leer). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<Project Path="src/IBKRTrader.Core/IBKRTrader.Core.csproj" />
|
||||
<Project Path="src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj" />
|
||||
<Project Path="src/IBKRTrader.Daemon/IBKRTrader.Daemon.csproj" />
|
||||
<Project Path="src/IBKRTrader.App.Avalonia/IBKRTrader.App.Avalonia.csproj" />
|
||||
<Project Path="src/IBKRTrader.Modules.CongressTrading/IBKRTrader.Modules.CongressTrading.csproj" />
|
||||
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
|
||||
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
<package pattern="IB.TWS.CSharpApi" />
|
||||
<!-- PDF-Export im Accounting-Modul (PDFsharp/MigraDoc, MIT) -->
|
||||
<package pattern="PDFsharp*" />
|
||||
<!-- Avalonia-Oberfläche (MIT) samt Renderer-Unterbau. SkiaSharp/HarfBuzz bringen die
|
||||
nativen Bibliotheken mit, Tmds.DBus und MicroCom sind Linux- bzw. Windows-Unterbau. -->
|
||||
<package pattern="Avalonia*" />
|
||||
<package pattern="SkiaSharp*" />
|
||||
<package pattern="HarfBuzzSharp*" />
|
||||
<package pattern="Tmds.DBus*" />
|
||||
<package pattern="MicroCom*" />
|
||||
<!-- Diagramme (kommen mit den neuen Modulen, s. Kommentar im Avalonia-csproj) -->
|
||||
<package pattern="LiveChartsCore*" />
|
||||
<!-- EF Core / Pomelo (MySQL/MariaDB).
|
||||
ACHTUNG: "Microsoft.EntityFrameworkCore.*" matcht das Basispaket OHNE Suffix NICHT –
|
||||
deshalb steht es zusätzlich einzeln. Ohne das schlägt ein Restore gegen einen leeren
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Avalonia.App"
|
||||
RequestedThemeVariant="Light">
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
|
||||
<!-- Projektweite Grundgestaltung. Bewusst hier zentral statt je Fenster wiederholt, damit
|
||||
alle Fenster gleich aussehen und die neuen Module direkt darauf aufsetzen können. -->
|
||||
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
<Setter Property="IsReadOnly" Value="True" />
|
||||
<Setter Property="CanUserResizeColumns" Value="True" />
|
||||
<Setter Property="CanUserSortColumns" Value="True" />
|
||||
<Setter Property="RowHeight" Value="24" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- KPI-Kachel: Ersatz für die zweizeiligen Labels der WinForms-Oberfläche. -->
|
||||
<Style Selector="Border.kpi">
|
||||
<Setter Property="Background" Value="#F5F5F5" />
|
||||
<Setter Property="BorderBrush" Value="#DDDDDD" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="Margin" Value="0,0,8,8" />
|
||||
<Setter Property="MinWidth" Value="150" />
|
||||
</Style>
|
||||
<Style Selector="Border.kpi TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="#666666" />
|
||||
</Style>
|
||||
<Style Selector="Border.kpi TextBlock.value">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- Werkzeugleiste oben in jedem Fenster (Ersatz für ToolStrip). -->
|
||||
<Style Selector="Border.toolbar">
|
||||
<Setter Property="Background" Value="#FAFAFA" />
|
||||
<Setter Property="BorderBrush" Value="#DDDDDD" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
</Style>
|
||||
<Style Selector="Border.toolbar Button">
|
||||
<Setter Property="Margin" Value="0,0,6,0" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
</Style>
|
||||
|
||||
<!-- Statusleiste unten (Ersatz für StatusStrip). -->
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="Background" Value="#FAFAFA" />
|
||||
<Setter Property="BorderBrush" Value="#DDDDDD" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
</Style>
|
||||
<Style Selector="Border.statusbar TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="#666666" />
|
||||
</Style>
|
||||
|
||||
<!-- Abschnittsüberschrift innerhalb eines Fensters. -->
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,8,0,4" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@@ -0,0 +1,50 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.App.Avalonia.Views;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia-Anwendungsobjekt. Verbindet den bereits laufenden Generic Host (Trading-Dienste,
|
||||
/// Module, Persistenz) mit der Oberfläche: beim Start wird der Launcher erzeugt und die Views von
|
||||
/// Core und Modulen werden bei der Shell registriert.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Wird von <see cref="Program"/> vor <c>StartWithClassicDesktopLifetime</c> gesetzt.
|
||||
/// Bewusst statisch: Avalonia erzeugt die Application-Instanz selbst, ein Konstruktorparameter
|
||||
/// ist deshalb nicht möglich.
|
||||
/// </summary>
|
||||
public static IServiceProvider? Services { get; set; }
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && Services != null)
|
||||
{
|
||||
var uiHost = Services.GetRequiredService<AvaloniaUiHost>();
|
||||
|
||||
CoreViews.Register(uiHost, Services);
|
||||
foreach (var module in Services.GetServices<IModule>())
|
||||
module.RegisterUi(uiHost, Services);
|
||||
ModuleViews.Register(uiHost, Services);
|
||||
ViewIcons.AssignDefaults(uiHost);
|
||||
|
||||
var launcher = new LauncherWindow(uiHost, Services);
|
||||
uiHost.SetMainWindow(launcher);
|
||||
desktop.MainWindow = launcher;
|
||||
|
||||
// Das Schließen des Launchers läuft über die Sicherheitsabfrage (wie bisher das X).
|
||||
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Plattformneutrale Oberfläche: dieselbe Anwendung läuft unter Windows und Linux und löst
|
||||
IBKRTrader.App (WinForms, net10.0-windows) ab. Der letzte WinForms-Stand liegt im
|
||||
Git-Tag winforms-final. -->
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>IBKRTrader.App.Avalonia</AssemblyName>
|
||||
<RootNamespace>IBKRTrader.App.Avalonia</RootNamespace>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
<!-- Layout wird deklarativ in .axaml gebaut, nicht zur Laufzeit im Code. Kompilierte Bindings
|
||||
verlangen ein x:DataType je Datenkontext – dafür fallen Tippfehler in Bindings beim
|
||||
Kompilieren auf statt erst zur Laufzeit. -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- AVLN3001: Die Fenster haben bewusst KEINEN parameterlosen Konstruktor – sie bekommen ihre
|
||||
Dienste per DI. Damit sind sie für den XAML-Previewer nicht ladbar, was wir hinnehmen:
|
||||
ein parameterloser Konstruktor würde ein Fenster ohne seine Dienste konstruierbar machen
|
||||
und genau den Fehler verdecken, den die Konstruktionsprüfung finden soll. -->
|
||||
<NoWarn>$(NoWarn);AVLN3001</NoWarn>
|
||||
<!-- Wie beim Daemon: ICU ist Pflicht. Ohne sie kippt die feste de-DE-Formatierung des
|
||||
PDF-Exports auf invariant und Windows-Zeitzonen-IDs lassen sich nicht mehr auflösen. -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- BEWUSST die 11er-Linie, nicht 12.x. LiveCharts2 (2.0.5, aktuellste Fassung) ist gegen
|
||||
Avalonia 11 gebaut und bricht unter 12: Avalonia.Input.Gestures.PinchEvent gibt es dort
|
||||
nicht mehr. Diagramme kommen mit den neuen Modulen – erst wenn LiveCharts2 Avalonia 12
|
||||
unterstützt, darf hier angehoben werden. Erfahrung aus PolytraderSharp. -->
|
||||
<PackageReference Include="Avalonia" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.19" />
|
||||
<!-- DataGrid folgt einer eigenen Versionsreihe und endet in der 11er-Linie bei 11.3.13. -->
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Die Symbole der bisherigen Oberfläche unverändert weiterverwenden (siehe Shell/ViewIcons.cs). -->
|
||||
<AvaloniaResource Include="..\..\Resources\*.png" Link="Assets\%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
using Avalonia;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia;
|
||||
|
||||
/// <summary>
|
||||
/// Einstiegspunkt der plattformneutralen Oberfläche.
|
||||
///
|
||||
/// <para>Bewusst zweigeteilt: <see cref="AppHostBuilder"/> stellt den Host mit Persistenz,
|
||||
/// Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst danach wird Avalonia
|
||||
/// daran gehängt. Derselbe Host trägt den kopflosen Linux-Dienst
|
||||
/// (<c>IBKRTrader.Daemon</c>).</para>
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
// Konstruktionsprüfung aller Fenster ohne Message-Loop und ohne laufende Dienste.
|
||||
if (HasFlag(args, "--smoke-ui")) return SmokeUi.Run();
|
||||
|
||||
var modules = AppHostBuilder.CreateModules();
|
||||
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
|
||||
|
||||
AppHostBuilder.RunStartupChecks(host.Services);
|
||||
|
||||
var logger = host.Services.GetRequiredService<LoggingService>();
|
||||
logger.Info("Core", "=== IBKRTrader startet ===");
|
||||
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
|
||||
|
||||
host.Start();
|
||||
StartModules(modules, logger);
|
||||
|
||||
try
|
||||
{
|
||||
App.Services = host.Services;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Geordnetes Herunterfahren – erst nachdem die Oberfläche beendet ist.
|
||||
StopModules(modules, logger);
|
||||
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Von Avalonia erwartete Fabrikmethode (auch vom XAML-Previewer genutzt).</summary>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
private static void StartModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
try { module.StartAsync(default).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { logger.Error(module.Name, $"{module.Name}: Start fehlgeschlagen.", ex); }
|
||||
}
|
||||
logger.Info("Core", "IBKRTrader bereit.");
|
||||
}
|
||||
|
||||
private static void StopModules(IReadOnlyList<IModule> modules, LoggingService logger)
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
try { module.StopAsync(default).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { logger.Warn(module.Name, $"{module.Name}: Stopp fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string flag) =>
|
||||
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using IBKRTrader.App.Avalonia.Views;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>. Verhält sich wie der frühere
|
||||
/// WinForms-<c>ShellUiHost</c>: je View höchstens ein Fenster, ein bereits offenes wird nach vorn
|
||||
/// geholt, der Offen-Status wird gemeldet (für die Markierung im Launcher).
|
||||
///
|
||||
/// <para>Der Core-Contract ist toolkit-neutral – <see cref="ModuleView.CreateView"/> liefert
|
||||
/// <see cref="object"/>. Hier wird auf <see cref="Window"/> gecastet: ein anderer Typ ist ein
|
||||
/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster ergeben.</para>
|
||||
/// </summary>
|
||||
public sealed class AvaloniaUiHost : IModuleUiHost
|
||||
{
|
||||
private readonly List<ModuleView> _views = [];
|
||||
private readonly Dictionary<string, Window> _open = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private Window? _mainWindow;
|
||||
private bool _shutdownDialogOpen;
|
||||
|
||||
/// <summary>True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde.</summary>
|
||||
public bool ShutdownConfirmed { get; private set; }
|
||||
|
||||
public event Action? OpenStateChanged;
|
||||
|
||||
public IReadOnlyList<ModuleView> Views => _views;
|
||||
|
||||
public void RegisterView(ModuleView view) => _views.Add(view);
|
||||
|
||||
/// <summary>Setzt das Hauptfenster (Launcher) – Ziel für <see cref="ActivateMain"/>.</summary>
|
||||
public void SetMainWindow(Window main) => _mainWindow = main;
|
||||
|
||||
public bool IsOpen(string viewId) => _open.ContainsKey(viewId);
|
||||
|
||||
public void ActivateMain()
|
||||
{
|
||||
if (_mainWindow is null) return;
|
||||
if (_mainWindow.WindowState == WindowState.Minimized)
|
||||
_mainWindow.WindowState = WindowState.Normal;
|
||||
_mainWindow.Activate();
|
||||
}
|
||||
|
||||
public void OpenView(string viewId)
|
||||
{
|
||||
var view = _views.FirstOrDefault(v => v.Id == viewId);
|
||||
if (view is not null) OpenView(view);
|
||||
}
|
||||
|
||||
private void OpenView(ModuleView view)
|
||||
{
|
||||
if (_open.TryGetValue(view.Id, out var existing))
|
||||
{
|
||||
if (existing.WindowState == WindowState.Minimized)
|
||||
existing.WindowState = WindowState.Normal;
|
||||
existing.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var window = (Window)view.CreateView();
|
||||
if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title;
|
||||
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
|
||||
_open[view.Id] = window;
|
||||
window.Closed += (_, _) =>
|
||||
{
|
||||
_open.Remove(view.Id);
|
||||
OpenStateChanged?.Invoke();
|
||||
};
|
||||
|
||||
window.Show();
|
||||
OpenStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt die Sicherheitsabfrage und fährt bei Bestätigung herunter. Aus jedem Fenster
|
||||
/// aufrufbar – auch aus Modul-Fenstern, die nur den Core-Contract kennen.
|
||||
///
|
||||
/// <para><c>async void</c> ist hier korrekt: die Methode ist ein Ereignis-Handler hinter dem
|
||||
/// synchronen Contract <see cref="IModuleUiHost.RequestShutdown"/>, und der Dialog muss
|
||||
/// erwartet werden. Ausnahmen können nicht entweichen – der Dialog wirft nicht, und der
|
||||
/// <c>finally</c>-Block gibt die Sperre in jedem Fall frei.</para>
|
||||
/// </summary>
|
||||
public async void RequestShutdown()
|
||||
{
|
||||
if (ShutdownConfirmed || _shutdownDialogOpen) return;
|
||||
_shutdownDialogOpen = true;
|
||||
try
|
||||
{
|
||||
var owner = _mainWindow;
|
||||
if (owner is null) return;
|
||||
|
||||
var confirmed = await new ShutdownConfirmWindow().ShowDialog<bool>(owner);
|
||||
if (!confirmed) return;
|
||||
|
||||
ShutdownConfirmed = true;
|
||||
CloseAllViews();
|
||||
|
||||
if (global::Avalonia.Application.Current?.ApplicationLifetime
|
||||
is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.Shutdown();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_shutdownDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Schließt alle offenen View-Fenster (beim Herunterfahren).</summary>
|
||||
public void CloseAllViews()
|
||||
{
|
||||
foreach (var window in _open.Values.ToList())
|
||||
window.Close();
|
||||
_open.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using IBKRTrader.App.Avalonia.Views;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>Registriert die Core-Ansichten (Dashboard, Workers, Logs, Settings) bei der Shell.</summary>
|
||||
public static class CoreViews
|
||||
{
|
||||
public static void Register(IModuleUiHost host, IServiceProvider sp)
|
||||
{
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.dashboard", Title = "Dashboard", Group = "Core", Order = 5,
|
||||
CreateView = () => new DashboardWindow(
|
||||
host,
|
||||
sp.GetRequiredService<DashboardService>(),
|
||||
sp.GetRequiredService<SettingsService>(),
|
||||
sp.GetServices<IModule>(),
|
||||
sp.GetRequiredService<IConfiguration>(),
|
||||
sp.GetServices<IWorker>())
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.workers", Title = "Workers / Services", Group = "Core", Order = 10,
|
||||
CreateView = () => new WorkersWindow(host, sp.GetRequiredService<WorkerEngine>())
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
|
||||
CreateView = () => new LogsWindow(host, sp.GetRequiredService<LoggingService>())
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.settings", Title = "Settings", Group = "Core", Order = 30,
|
||||
CreateView = () => new SettingsWindow(host, sp.GetRequiredService<SettingsService>())
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Registriert die Fenster der Module bei der Shell.
|
||||
///
|
||||
/// <para><b>Warum hier und nicht im Modul?</b> Ein Modul, das sein eigenes Fenster erzeugt, müsste
|
||||
/// Avalonia referenzieren – und wäre damit nicht mehr kopflos auf Linux lauffähig. Die
|
||||
/// Modulprojekte bleiben deshalb frei von UI-Code; ihr <c>RegisterUi</c> ist leer, und die Shell
|
||||
/// verdrahtet die Fenster zentral. Die Modul-Dienste kommen unverändert aus dem DI-Container.</para>
|
||||
///
|
||||
/// <para><b>Stand:</b> noch leer – die drei Modul-Fenster (CongressTrading, Supervisor,
|
||||
/// Accounting) werden in der nächsten Etappe portiert. Bis dahin zeigt die Avalonia-Shell nur die
|
||||
/// Core-Ansichten; die WinForms-Shell bleibt daneben vollständig nutzbar.</para>
|
||||
/// </summary>
|
||||
public static class ModuleViews
|
||||
{
|
||||
public static void Register(IModuleUiHost host, IServiceProvider sp)
|
||||
{
|
||||
// Noch keine portierten Modul-Fenster. Das Muster steht in der WinForms-Fassung
|
||||
// (UI/ModuleViews.cs) und wird hier eins zu eins übernommen.
|
||||
}
|
||||
|
||||
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
|
||||
internal static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register)
|
||||
{
|
||||
if (sp.GetServices<IModule>().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)))
|
||||
register();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Die Dienste, die es nur mit Oberfläche gibt. Alles andere kommt aus
|
||||
/// <c>AppHostBuilder</c> und ist mit dem kopflosen Daemon geteilt.
|
||||
/// </summary>
|
||||
public static class ShellServices
|
||||
{
|
||||
public static void Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddSingleton<AvaloniaUiHost>();
|
||||
services.AddSingleton<IModuleUiHost>(sp => sp.GetRequiredService<AvaloniaUiHost>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using IBKRTrader.App.Avalonia.ViewModels;
|
||||
using IBKRTrader.App.Avalonia.Views;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Konstruktionsprüfung aller Fenster – ohne Message-Loop und ohne laufende Dienste.
|
||||
///
|
||||
/// <para>Nachfolger des <c>--smoke-ui</c>-Laufs der WinForms-Shell, der Konstruktionsfehler
|
||||
/// zuverlässig gefangen hat. Der Host wird bewusst <b>nicht</b> gestartet: sonst liefen Worker,
|
||||
/// Broker-Verbindungen und Marktdaten-Abrufe gegen die echten Endpunkte an – für eine reine
|
||||
/// Konstruktionsprüfung unerwünscht, auf einem Build-Server schlicht falsch.</para>
|
||||
///
|
||||
/// <para>Im Gegensatz zu WinForms braucht Avalonia dafür kein Anzeigegerät:
|
||||
/// <c>SetupWithoutStarting</c> initialisiert das Framework, ohne ein Fenster zu zeigen. Damit ist
|
||||
/// diese Prüfung erstmals CI-tauglich.</para>
|
||||
/// </summary>
|
||||
public static class SmokeUi
|
||||
{
|
||||
public static int Run()
|
||||
{
|
||||
Program.BuildAvaloniaApp().SetupWithoutStarting();
|
||||
|
||||
var modules = AppHostBuilder.CreateModules();
|
||||
using var host = AppHostBuilder.Build(modules, ShellServices.Register);
|
||||
App.Services = host.Services;
|
||||
|
||||
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
|
||||
CoreViews.Register(uiHost, host.Services);
|
||||
foreach (var module in modules)
|
||||
module.RegisterUi(uiHost, host.Services);
|
||||
ModuleViews.Register(uiHost, host.Services);
|
||||
ViewIcons.AssignDefaults(uiHost);
|
||||
|
||||
var failures = 0;
|
||||
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ===");
|
||||
|
||||
foreach (var view in uiHost.Views)
|
||||
failures += Check(view.Id, view.Title, () => view.CreateView());
|
||||
|
||||
failures += Check("shell.launcher", "Launcher", () => new LauncherWindow(uiHost, host.Services));
|
||||
failures += Check("shell.shutdown", "Beenden-Abfrage", () => new ShutdownConfirmWindow());
|
||||
|
||||
// Die Einstellungsmaske entsteht aus den Attributen von AppSettings. Ein Fenster kann
|
||||
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen –
|
||||
// deshalb hier gegen die tatsächliche Feldzahl prüfen.
|
||||
failures += CheckSettingsForm(host.Services.GetRequiredService<SettingsService>());
|
||||
|
||||
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static int Check(string id, string title, Func<object> construct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = construct();
|
||||
Console.WriteLine($"[OK] {id} ({title})");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[FEHLER] {id}: {ex.GetType().Name}: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int CheckSettingsForm(SettingsService settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sections = SettingsModelBuilder.Build(settings.Settings);
|
||||
var fieldCount = sections.Sum(s => s.Fields.Count);
|
||||
|
||||
if (sections.Count == 0 || fieldCount == 0)
|
||||
{
|
||||
Console.WriteLine("[FEHLER] Einstellungsmaske: keine Felder aus AppSettings ermittelt " +
|
||||
"(Category-/DisplayName-Attribute verloren?).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[OK] Einstellungsmaske: {sections.Count} Abschnitte, {fieldCount} Felder");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[FEHLER] Einstellungsmaske: {ex.GetType().Name}: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Löst die toolkit-neutralen <see cref="ModuleView.IconKey"/>-Schlüssel gegen die
|
||||
/// Avalonia-Bildressourcen auf. Gegenstück zum gleichnamigen Helfer der WinForms-Shell –
|
||||
/// <b>dieselben Schlüssel, dieselben PNG-Dateien</b>, sodass Core und Module unverändert bleiben.
|
||||
/// </summary>
|
||||
public static class ViewIcons
|
||||
{
|
||||
/// <summary>Symbol-Schlüssel → Dateiname unter <c>Resources/</c> (als Avalonia-Asset eingebettet).</summary>
|
||||
private static readonly Dictionary<string, string> FileByKey = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["dashboard"] = "dashboard.png",
|
||||
["workers"] = "system_time.png",
|
||||
["logs"] = "error_log.png",
|
||||
["settings"] = "setting_tools.png",
|
||||
["congresstrading"] = "cross_reference.png",
|
||||
["accounting"] = "coins_in_hand.png",
|
||||
["supervisor"] = "token_quantifier.png",
|
||||
};
|
||||
|
||||
/// <summary>Standard-Symbolschlüssel je View-ID – identisch zur WinForms-Shell.</summary>
|
||||
private static readonly Dictionary<string, string> DefaultKeyByViewId = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["core.dashboard"] = "dashboard",
|
||||
["core.workers"] = "workers",
|
||||
["core.logs"] = "logs",
|
||||
["core.settings"] = "settings",
|
||||
["congresstrading.main"] = "congresstrading",
|
||||
["accounting.main"] = "accounting",
|
||||
["supervisor.main"] = "supervisor",
|
||||
};
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Bitmap?> Cache = new();
|
||||
|
||||
/// <summary>Bild zum Schlüssel, oder <c>null</c> (kein Symbol / Datei fehlt).</summary>
|
||||
public static Bitmap? Resolve(string? iconKey)
|
||||
{
|
||||
if (iconKey is null || !FileByKey.TryGetValue(iconKey, out var file)) return null;
|
||||
|
||||
return Cache.GetOrAdd(iconKey, _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = AssetLoader.Open(
|
||||
new Uri($"avares://IBKRTrader.App.Avalonia/Assets/{file}"));
|
||||
return new Bitmap(stream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ein fehlendes Symbol darf die Oberfläche nie aufhalten – dann eben nur Text.
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist.</summary>
|
||||
public static void AssignDefaults(IModuleUiHost host)
|
||||
{
|
||||
foreach (var view in host.Views)
|
||||
if (view.IconKey is null && DefaultKeyByViewId.TryGetValue(view.Id, out var key))
|
||||
view.IconKey = key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen
|
||||
/// allen Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract
|
||||
/// <see cref="IModuleUiHost"/> und funktioniert deshalb auch aus Modul-Fenstern.
|
||||
///
|
||||
/// <para>Gegenstück zum gleichnamigen WinForms-Helfer – gleiches Verhalten, anderes Toolkit.</para>
|
||||
/// </summary>
|
||||
public static class WindowMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdrahtet ein <see cref="Menu"/> mit der Fensterliste: füllt es sofort und baut es bei
|
||||
/// jeder Änderung des Offen-Status neu auf. Die Registrierung wird beim Entladen gelöst.
|
||||
/// </summary>
|
||||
/// <param name="currentViewId">ID der eigenen View, oder <c>null</c> im Launcher.</param>
|
||||
public static void Wire(Menu menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
void Refresh()
|
||||
{
|
||||
// Der Offen-Status kann aus einem beliebigen Fenster gemeldet werden – der Aufbau
|
||||
// der Menüleiste gehört aber auf den UI-Thread.
|
||||
if (Dispatcher.UIThread.CheckAccess()) Populate(menu, host, currentViewId);
|
||||
else Dispatcher.UIThread.Post(() => Populate(menu, host, currentViewId));
|
||||
}
|
||||
|
||||
Populate(menu, host, currentViewId);
|
||||
host.OpenStateChanged += Refresh;
|
||||
menu.DetachedFromVisualTree += (_, _) => host.OpenStateChanged -= Refresh;
|
||||
}
|
||||
|
||||
/// <summary>Baut die Menüleiste komplett neu auf.</summary>
|
||||
public static void Populate(Menu menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
var items = new List<Control>
|
||||
{
|
||||
BuildItem("Launcher", null, isCurrent: currentViewId is null,
|
||||
isOpen: false, onClick: host.ActivateMain)
|
||||
};
|
||||
|
||||
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
var id = view.Id;
|
||||
items.Add(BuildItem(view.Title, view.IconKey,
|
||||
isCurrent: id == currentViewId,
|
||||
isOpen: host.IsOpen(id),
|
||||
onClick: () => host.OpenView(id)));
|
||||
}
|
||||
|
||||
// Kontextabhängige rechte Aktion: nur der Launcher darf die Anwendung beenden; jedes
|
||||
// andere Fenster bietet nur „Fenster schließen" (Module laufen weiter).
|
||||
if (currentViewId is null)
|
||||
{
|
||||
items.Add(BuildItem("Beenden", null, false, false, host.RequestShutdown, alignRight: true));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(BuildItem("Fenster schließen", null, false, false,
|
||||
() => (menu.GetVisualRoot() as Window)?.Close(), alignRight: true));
|
||||
}
|
||||
|
||||
menu.ItemsSource = items;
|
||||
}
|
||||
|
||||
private static MenuItem BuildItem(string title, string? iconKey, bool isCurrent, bool isOpen,
|
||||
Action onClick, bool alignRight = false)
|
||||
{
|
||||
var item = new MenuItem
|
||||
{
|
||||
Header = title,
|
||||
FontWeight = isCurrent ? FontWeight.Bold : FontWeight.Normal,
|
||||
// Offene Fenster werden hervorgehoben – Ersatz für das Häkchen der WinForms-Leiste.
|
||||
Foreground = isOpen && !isCurrent ? Brushes.SteelBlue : null,
|
||||
};
|
||||
|
||||
if (alignRight) item.HorizontalAlignment = HorizontalAlignment.Right;
|
||||
|
||||
var icon = ViewIcons.Resolve(iconKey);
|
||||
if (icon is not null)
|
||||
item.Icon = new Image { Source = icon, Width = 16, Height = 16 };
|
||||
|
||||
item.Click += (_, _) => onClick();
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace IBKRTrader.App.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Zeilentypen für die DataGrids der Core-Ansichten.
|
||||
///
|
||||
/// <para>Bewusst benannte Records statt der anonymen Typen, die die WinForms-Fassung an
|
||||
/// <c>DataGridView.DataSource</c> gehängt hat: Avalonias <c>DataGrid</c> bindet über
|
||||
/// kompilierte Bindings gegen einen bekannten Typ. Anonyme Typen sind <c>internal</c> und
|
||||
/// funktionieren dort nur über Reflexion – mit benannten Records bleibt die Spaltendefinition
|
||||
/// im XAML prüfbar.</para>
|
||||
/// </summary>
|
||||
public sealed record ModuleRow(string Name, string Prefix, string Status);
|
||||
|
||||
/// <summary>
|
||||
/// Eine Zeile im Live-Log. Die Farbe hängt am Eintrag statt an einer Selektion – WinForms färbte
|
||||
/// über <c>SelectionColor</c> der RichTextBox ein, in Avalonia wird je Element gebunden.
|
||||
/// </summary>
|
||||
public sealed record LogRow(string Text, global::Avalonia.Media.IBrush Color);
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>Ein bearbeitbares Einzelfeld der Einstellungen.</summary>
|
||||
public sealed class SettingsField
|
||||
{
|
||||
public required string DisplayName { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required Type ValueType { get; init; }
|
||||
public required bool IsPassword { get; init; }
|
||||
|
||||
public required Func<object?> Get { get; init; }
|
||||
public required Action<object?> Set { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Ein Abschnitt (entspricht einem aufklappbaren Knoten des früheren PropertyGrid).</summary>
|
||||
public sealed record SettingsSection(string Title, IReadOnlyList<SettingsField> Fields);
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Eingabemaske der Einstellungen aus den Attributen von <c>AppSettings</c>.
|
||||
///
|
||||
/// <para><b>Warum aus Attributen:</b> Die WinForms-Fassung zeigte <c>AppSettings</c> in einem
|
||||
/// <c>PropertyGrid</c>. Avalonia hat dafür kein Gegenstück. Die Klassen tragen bereits
|
||||
/// <see cref="CategoryAttribute"/>, <see cref="DisplayNameAttribute"/> und
|
||||
/// <see cref="DescriptionAttribute"/> – daraus lässt sich die Maske erzeugen, statt sie von Hand
|
||||
/// zu pflegen. Eine neue Einstellung erscheint damit automatisch, ohne dass jemand die Oberfläche
|
||||
/// anfasst; genau das war der Vorteil des PropertyGrid, und er bleibt erhalten.</para>
|
||||
/// </summary>
|
||||
public static class SettingsModelBuilder
|
||||
{
|
||||
/// <summary>Typen, die als Eingabefeld dargestellt werden. Alles andere gilt als Unterabschnitt.</summary>
|
||||
private static bool IsLeaf(Type t) =>
|
||||
t == typeof(string) || t.IsEnum ||
|
||||
t == typeof(int) || t == typeof(long) || t == typeof(double) ||
|
||||
t == typeof(decimal) || t == typeof(bool);
|
||||
|
||||
/// <summary>Zerlegt das Einstellungsobjekt in Abschnitte mit Feldern.</summary>
|
||||
public static IReadOnlyList<SettingsSection> Build(object root)
|
||||
{
|
||||
var sections = new List<SettingsSection>();
|
||||
Walk(root, prefix: null, sections);
|
||||
return sections;
|
||||
}
|
||||
|
||||
private static void Walk(object owner, string? prefix, List<SettingsSection> sections)
|
||||
{
|
||||
var fields = new List<SettingsField>();
|
||||
|
||||
foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue;
|
||||
|
||||
var title = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? prop.Name;
|
||||
|
||||
if (IsLeaf(prop.PropertyType))
|
||||
{
|
||||
if (!prop.CanWrite) continue; // z. B. berechnete Eigenschaften
|
||||
|
||||
var target = owner; // für den Abschluss festhalten
|
||||
fields.Add(new SettingsField
|
||||
{
|
||||
DisplayName = title,
|
||||
Description = prop.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "",
|
||||
ValueType = prop.PropertyType,
|
||||
IsPassword = prop.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true,
|
||||
Get = () => prop.GetValue(target),
|
||||
Set = v => prop.SetValue(target, v)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verschachteltes Einstellungsobjekt → eigener Abschnitt. Nur eigene Typen verfolgen,
|
||||
// damit die Rekursion nicht in Framework-Typen abbiegt.
|
||||
if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader") == true)
|
||||
{
|
||||
var child = prop.GetValue(owner);
|
||||
if (child is not null)
|
||||
Walk(child, prefix is null ? title : $"{prefix} · {title}", sections);
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.Count > 0)
|
||||
sections.Add(new SettingsSection(prefix ?? "Allgemein", fields));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
|
||||
xmlns:vm="clr-namespace:IBKRTrader.App.Avalonia.ViewModels"
|
||||
x:Class="IBKRTrader.App.Avalonia.Views.DashboardWindow"
|
||||
Title="Dashboard"
|
||||
Width="960" Height="600"
|
||||
MinWidth="640" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="RefreshButton" Content="Aktualisieren" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<TextBlock Grid.Row="0" x:Name="ModeText"
|
||||
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
|
||||
<!-- Kennzahlen als Kacheln statt einer langen Label-Zeile. -->
|
||||
<WrapPanel Grid.Row="1" x:Name="KpiPanel" />
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="section" Text="Geladene Module" />
|
||||
|
||||
<dg:DataGrid Grid.Row="3" x:Name="ModulesGrid" AutoGenerateColumns="False"
|
||||
x:DataType="vm:ModuleRow">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Name}" Width="200" />
|
||||
<dg:DataGridTextColumn Header="Präfix" Binding="{Binding Prefix}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,97 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.App.Avalonia.ViewModels;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Gesamtüberblick (Handelsmodus, aggregierte Kennzahlen, geladene Module).
|
||||
/// DB-Zugriffe laufen NUR auf Anzeige und Nutzerinteraktion – nie im Konstruktor, damit die
|
||||
/// Konstruktionsprüfung (<c>--smoke-ui</c>) auch ohne Datenbank fehlerfrei durchläuft.
|
||||
/// </summary>
|
||||
public partial class DashboardWindow : Window
|
||||
{
|
||||
private readonly DashboardService _dashboard;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IReadOnlyList<IModule> _modules;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly int _workerCount;
|
||||
|
||||
public DashboardWindow(IModuleUiHost uiHost,
|
||||
DashboardService dashboard,
|
||||
SettingsService settings,
|
||||
IEnumerable<IModule> modules,
|
||||
IConfiguration config,
|
||||
IEnumerable<IWorker> workers)
|
||||
{
|
||||
_dashboard = dashboard;
|
||||
_settings = settings;
|
||||
_modules = modules.ToList();
|
||||
_config = config;
|
||||
_workerCount = workers.Count();
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.dashboard");
|
||||
|
||||
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
|
||||
Opened += async (_, _) => await RefreshAsync();
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
var t = _settings.Settings.Trading;
|
||||
var modeText = this.FindControl<TextBlock>("ModeText")!;
|
||||
modeText.Text = $"Trading: {t.Mode} – {(t.TradingEnabled ? "AKTIV" : "inaktiv")}";
|
||||
modeText.Foreground = t.TradingEnabled ? Brushes.SeaGreen : Brushes.Gray;
|
||||
|
||||
this.FindControl<DataGrid>("ModulesGrid")!.ItemsSource = _modules
|
||||
.Select(m => new ModuleRow(m.Name, m.DbPrefix, m.GetActivationBlocker(_config) ?? "aktivierbar"))
|
||||
.ToList();
|
||||
|
||||
var status = this.FindControl<TextBlock>("StatusText")!;
|
||||
try
|
||||
{
|
||||
var snap = await _dashboard.GetSnapshotAsync();
|
||||
ShowKpis(
|
||||
("Offene Positionen", snap.OpenPositions.ToString()),
|
||||
("Exposure", snap.TotalExposure.ToString("N2")),
|
||||
("Trades gesamt", snap.TotalTrades.ToString()),
|
||||
("Worker / Services", _workerCount.ToString()));
|
||||
status.Text = $"Aktualisiert: {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowKpis(
|
||||
("Offene Positionen", "n/v"),
|
||||
("Exposure", "n/v"),
|
||||
("Trades gesamt", "n/v"),
|
||||
("Worker / Services", _workerCount.ToString()));
|
||||
status.Text = $"DB nicht erreichbar: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Baut die Kennzahlen-Kacheln neu. Gestaltung kommt aus der Klasse "kpi" in App.axaml.</summary>
|
||||
private void ShowKpis(params (string Caption, string Value)[] kpis)
|
||||
{
|
||||
var panel = this.FindControl<WrapPanel>("KpiPanel")!;
|
||||
panel.Children.Clear();
|
||||
|
||||
foreach (var (caption, value) in kpis)
|
||||
{
|
||||
var stack = new StackPanel();
|
||||
stack.Children.Add(new TextBlock { Text = caption, Classes = { "caption" } });
|
||||
stack.Children.Add(new TextBlock { Text = value, Classes = { "value" } });
|
||||
panel.Children.Add(new Border { Classes = { "kpi" }, Child = stack });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Avalonia.Views.LauncherWindow"
|
||||
Title="IBKRTrader — Launcher"
|
||||
Width="820" Height="560"
|
||||
MinWidth="560" MinHeight="360"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<!-- Fensterleiste: je registrierter View eine Schaltfläche, Symbol über Text. -->
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="ViewButtons">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" Text="Start..." />
|
||||
</Border>
|
||||
|
||||
<Border Background="White">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="6">
|
||||
<TextBlock Text="IBKRTrader"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Text="Fenster über die Leiste oben öffnen."
|
||||
Foreground="#666666"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EnvironmentText"
|
||||
Foreground="#999999" FontSize="11"
|
||||
Margin="0,12,0,0"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,107 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher – das Basisfenster der Shell. Zeigt je registrierter View eine Schaltfläche und trägt
|
||||
/// das gemeinsame Fenster-Menü. Die inhaltlichen Ansichten sind eigenständige Fenster.
|
||||
///
|
||||
/// <para>Die Dienste laufen bereits, wenn dieses Fenster erscheint: der Host wird in
|
||||
/// <c>Program.Main</c> vor Avalonia gestartet. Der Launcher startet nichts, er zeigt nur an.</para>
|
||||
/// </summary>
|
||||
public partial class LauncherWindow : Window
|
||||
{
|
||||
private readonly AvaloniaUiHost _uiHost;
|
||||
private readonly Dictionary<string, Button> _viewButtons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Parameterloser Konstruktor nur für den XAML-Previewer.</summary>
|
||||
public LauncherWindow() : this(new AvaloniaUiHost(), null) { }
|
||||
|
||||
public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider? services)
|
||||
{
|
||||
_uiHost = uiHost;
|
||||
InitializeComponent();
|
||||
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, _uiHost, currentViewId: null);
|
||||
BuildViewButtons();
|
||||
|
||||
this.FindControl<TextBlock>("EnvironmentText")!.Text =
|
||||
$"{Environment.OSVersion.Platform} · .NET {Environment.Version} · Zeitzone {AppTimeZone.CurrentId}";
|
||||
|
||||
_uiHost.OpenStateChanged += UpdateButtonStates;
|
||||
Closed += (_, _) => _uiHost.OpenStateChanged -= UpdateButtonStates;
|
||||
|
||||
SetStatus(services is null ? "Vorschau" : "Bereit");
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
// Auch das Schließen-X läuft über die Sicherheitsabfrage.
|
||||
if (!_uiHost.ShutdownConfirmed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
_uiHost.RequestShutdown();
|
||||
return;
|
||||
}
|
||||
base.OnClosing(e);
|
||||
}
|
||||
|
||||
private void BuildViewButtons()
|
||||
{
|
||||
var buttons = new List<Control>();
|
||||
|
||||
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
var id = view.Id;
|
||||
var content = new StackPanel { Spacing = 2, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
|
||||
var icon = ViewIcons.Resolve(view.IconKey);
|
||||
if (icon is not null)
|
||||
content.Children.Add(new Image
|
||||
{
|
||||
Source = icon, Width = 32, Height = 32,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
});
|
||||
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = view.Title, FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
});
|
||||
|
||||
var button = new Button { Content = content, Padding = new global::Avalonia.Thickness(10, 6) };
|
||||
button.Click += (_, _) => _uiHost.OpenView(id);
|
||||
|
||||
_viewButtons[id] = button;
|
||||
buttons.Add(button);
|
||||
}
|
||||
|
||||
this.FindControl<ItemsControl>("ViewButtons")!.ItemsSource = buttons;
|
||||
}
|
||||
|
||||
/// <summary>Hebt die Schaltflächen der bereits offenen Fenster hervor.</summary>
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
if (!Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
Dispatcher.UIThread.Post(UpdateButtonStates);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (id, button) in _viewButtons)
|
||||
button.BorderBrush = _uiHost.IsOpen(id) ? Brushes.SteelBlue : null;
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text =
|
||||
$"Status: {text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:IBKRTrader.App.Avalonia.ViewModels"
|
||||
x:Class="IBKRTrader.App.Avalonia.Views.LogsWindow"
|
||||
Title="Logs"
|
||||
Width="1000" Height="650"
|
||||
MinWidth="640" MinHeight="360"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button x:Name="ClearButton" Content="Leeren" />
|
||||
<Button x:Name="CopyButton" Content="Alles kopieren" />
|
||||
<CheckBox x:Name="AutoScrollCheck" Content="Automatisch scrollen"
|
||||
IsChecked="True" Margin="12,0,0,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Filter:" Margin="16,0,6,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="FilterBox" Width="220" Watermark="Text oder Modul" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<!-- Dunkles Terminal-Erscheinungsbild wie in der bisherigen RichTextBox. Statt der
|
||||
Selection-Einfärbung von WinForms wird hier je Eintrag ein eingefärbtes Element
|
||||
erzeugt – das ist der Weg, den Avalonia dafür vorsieht. -->
|
||||
<ScrollViewer x:Name="LogScroller" Background="#14141E">
|
||||
<ItemsControl x:Name="LogList" Margin="8">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LogRow">
|
||||
<TextBlock Text="{Binding Text}"
|
||||
Foreground="{Binding Color}"
|
||||
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
|
||||
FontSize="12"
|
||||
TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.App.Avalonia.ViewModels;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Live-Log, an das <see cref="LoggingService.EntryWritten"/>-Ereignis gebunden.
|
||||
///
|
||||
/// <para>Einfärbung und der Wechsel auf den UI-Thread liegen hier, nicht mehr im Logging-Dienst –
|
||||
/// der Core trägt seit der Portierung keine UI-Abhängigkeit. Der Dienst schreibt aus beliebigen
|
||||
/// Worker-Threads, deshalb geht jeder Eintrag über den Dispatcher.</para>
|
||||
/// </summary>
|
||||
public partial class LogsWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Obergrenze der angezeigten Zeilen. Ohne sie wüchse die Liste im Dauerbetrieb unbegrenzt –
|
||||
/// die vollständige Historie steht ohnehin in den Logdateien.
|
||||
/// </summary>
|
||||
private const int MaxLines = 5000;
|
||||
|
||||
private static readonly IBrush ColorInfo = new SolidColorBrush(Color.FromRgb(150, 210, 150));
|
||||
private static readonly IBrush ColorWarn = new SolidColorBrush(Color.FromRgb(255, 190, 60));
|
||||
private static readonly IBrush ColorError = new SolidColorBrush(Color.FromRgb(255, 80, 80));
|
||||
|
||||
private readonly LoggingService _logger;
|
||||
private readonly ObservableCollection<LogRow> _rows = [];
|
||||
private string _filter = "";
|
||||
|
||||
public LogsWindow(IModuleUiHost uiHost, LoggingService logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.logs");
|
||||
|
||||
this.FindControl<ItemsControl>("LogList")!.ItemsSource = _rows;
|
||||
|
||||
this.FindControl<Button>("ClearButton")!.Click += (_, _) => { _rows.Clear(); SetStatus("Geleert."); };
|
||||
this.FindControl<Button>("CopyButton")!.Click += async (_, _) => await CopyAllAsync();
|
||||
this.FindControl<TextBox>("FilterBox")!.TextChanged += (s, _) =>
|
||||
_filter = ((TextBox)s!).Text ?? "";
|
||||
|
||||
_logger.EntryWritten += OnEntryWritten;
|
||||
Closed += (_, _) => _logger.EntryWritten -= OnEntryWritten;
|
||||
|
||||
SetStatus("Bereit.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// Wird aus beliebigen Worker-Threads gerufen. Ein Fehler hier darf den schreibenden Worker
|
||||
/// niemals mitreißen – deshalb der umschließende Schutz.
|
||||
/// </summary>
|
||||
private void OnEntryWritten(LogEntry e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess()) Append(e);
|
||||
else Dispatcher.UIThread.Post(() => Append(e));
|
||||
}
|
||||
catch { /* Fenster wird gerade geschlossen */ }
|
||||
}
|
||||
|
||||
private void Append(LogEntry e)
|
||||
{
|
||||
var text = LoggingService.Format(e);
|
||||
|
||||
if (_filter.Length > 0 && !text.Contains(_filter, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var color = e.Level switch
|
||||
{
|
||||
AppLogLevel.Warn => ColorWarn,
|
||||
AppLogLevel.Error => ColorError,
|
||||
_ => ColorInfo
|
||||
};
|
||||
|
||||
_rows.Add(new LogRow(text, color));
|
||||
while (_rows.Count > MaxLines) _rows.RemoveAt(0);
|
||||
|
||||
if (this.FindControl<CheckBox>("AutoScrollCheck")!.IsChecked == true)
|
||||
this.FindControl<ScrollViewer>("LogScroller")!.ScrollToEnd();
|
||||
}
|
||||
|
||||
private async Task CopyAllAsync()
|
||||
{
|
||||
var clipboard = GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) { SetStatus("Zwischenablage nicht verfügbar."); return; }
|
||||
|
||||
await clipboard.SetTextAsync(string.Join(Environment.NewLine, _rows.Select(r => r.Text)));
|
||||
SetStatus($"{_rows.Count} Zeilen kopiert.");
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Avalonia.Views.SettingsWindow"
|
||||
Title="Settings"
|
||||
Width="860" Height="740"
|
||||
MinWidth="620" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="SaveButton" Content="Speichern" />
|
||||
<Button x:Name="ReloadButton" Content="Verwerfen"
|
||||
ToolTip.Tip="Lädt die gespeicherten Werte neu und verwirft ungespeicherte Änderungen." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel x:Name="SectionPanel" Margin="14" Spacing="4" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.App.Avalonia.ViewModels;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Time;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Einstellungen. Ersetzt das <c>PropertyGrid</c> der WinForms-Fassung durch eine
|
||||
/// aus den Attributen erzeugte Maske (siehe <see cref="SettingsModelBuilder"/>).
|
||||
///
|
||||
/// <para>Geändert wird direkt auf dem <c>AppSettings</c>-Objekt; „Speichern" schreibt es nach
|
||||
/// <c>settings.json</c>, „Verwerfen" lädt die Datei neu. Zahlen werden ausdrücklich gegen
|
||||
/// <see cref="CultureInfo.InvariantCulture"/> gelesen – die Datei ist maschinenlesbar und darf
|
||||
/// nicht von der Kultur des Rechners abhängen.</para>
|
||||
/// </summary>
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public SettingsWindow(IModuleUiHost uiHost, SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.settings");
|
||||
|
||||
this.FindControl<Button>("SaveButton")!.Click += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_settings.Save();
|
||||
SetStatus("Gespeichert. Zeitzonen-Änderungen greifen erst nach einem Neustart.");
|
||||
}
|
||||
catch (Exception ex) { SetStatus($"Speichern fehlgeschlagen: {ex.Message}"); }
|
||||
};
|
||||
|
||||
this.FindControl<Button>("ReloadButton")!.Click += (_, _) =>
|
||||
{
|
||||
_settings.Load();
|
||||
BuildForm();
|
||||
SetStatus("Gespeicherte Werte neu geladen.");
|
||||
};
|
||||
|
||||
BuildForm();
|
||||
SetStatus("Bereit.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private void BuildForm()
|
||||
{
|
||||
var panel = this.FindControl<StackPanel>("SectionPanel")!;
|
||||
panel.Children.Clear();
|
||||
|
||||
foreach (var section in SettingsModelBuilder.Build(_settings.Settings))
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("240,*"),
|
||||
Margin = new Thickness(4, 4, 4, 12)
|
||||
};
|
||||
|
||||
for (var i = 0; i < section.Fields.Count; i++)
|
||||
{
|
||||
var field = section.Fields[i];
|
||||
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
||||
|
||||
var label = new TextBlock
|
||||
{
|
||||
Text = field.DisplayName,
|
||||
Margin = new Thickness(0, 6, 10, 6),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(field.Description))
|
||||
ToolTip.SetTip(label, field.Description);
|
||||
|
||||
var editor = CreateEditor(field);
|
||||
editor.Margin = new Thickness(0, 4, 0, 4);
|
||||
if (!string.IsNullOrWhiteSpace(field.Description))
|
||||
ToolTip.SetTip(editor, field.Description);
|
||||
|
||||
Grid.SetRow(label, i); Grid.SetColumn(label, 0);
|
||||
Grid.SetRow(editor, i); Grid.SetColumn(editor, 1);
|
||||
grid.Children.Add(label);
|
||||
grid.Children.Add(editor);
|
||||
}
|
||||
|
||||
panel.Children.Add(new Expander
|
||||
{
|
||||
Header = section.Title,
|
||||
IsExpanded = true,
|
||||
Content = grid,
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wählt das Eingabeelement passend zum Typ des Feldes.</summary>
|
||||
private static Control CreateEditor(SettingsField field)
|
||||
{
|
||||
if (field.ValueType == typeof(bool))
|
||||
{
|
||||
var check = new CheckBox { IsChecked = (bool?)field.Get() };
|
||||
check.IsCheckedChanged += (_, _) => field.Set(check.IsChecked == true);
|
||||
return check;
|
||||
}
|
||||
|
||||
if (field.ValueType.IsEnum)
|
||||
{
|
||||
var combo = new ComboBox
|
||||
{
|
||||
ItemsSource = Enum.GetValues(field.ValueType),
|
||||
SelectedItem = field.Get(),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MinWidth = 200
|
||||
};
|
||||
combo.SelectionChanged += (_, _) => { if (combo.SelectedItem is not null) field.Set(combo.SelectedItem); };
|
||||
return combo;
|
||||
}
|
||||
|
||||
if (field.ValueType == typeof(int) || field.ValueType == typeof(long) ||
|
||||
field.ValueType == typeof(double) || field.ValueType == typeof(decimal))
|
||||
{
|
||||
var numeric = new NumericUpDown
|
||||
{
|
||||
Value = ToDecimal(field.Get()),
|
||||
Increment = field.ValueType == typeof(double) || field.ValueType == typeof(decimal) ? 0.5m : 1m,
|
||||
FormatString = field.ValueType == typeof(double) || field.ValueType == typeof(decimal) ? "0.###" : "0",
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MinWidth = 200
|
||||
};
|
||||
numeric.ValueChanged += (_, _) =>
|
||||
{
|
||||
if (numeric.Value is not { } v) return;
|
||||
field.Set(Convert.ChangeType(v, field.ValueType, CultureInfo.InvariantCulture));
|
||||
};
|
||||
return numeric;
|
||||
}
|
||||
|
||||
var box = new TextBox { Text = field.Get()?.ToString() ?? "" };
|
||||
// Kennwortfelder (DB-Passwort, Flex-Token) nicht im Klartext anzeigen.
|
||||
if (field.IsPassword) box.PasswordChar = '•';
|
||||
box.TextChanged += (_, _) => field.Set(box.Text ?? "");
|
||||
return box;
|
||||
}
|
||||
|
||||
private static decimal ToDecimal(object? value) =>
|
||||
value is null ? 0m : Convert.ToDecimal(value, CultureInfo.InvariantCulture);
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="IBKRTrader.App.Avalonia.Views.ShutdownConfirmWindow"
|
||||
Title="Beenden"
|
||||
Width="440" SizeToContent="Height"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<StackPanel Margin="20" Spacing="14">
|
||||
<TextBlock Text="IBKRTrader wirklich beenden?"
|
||||
FontSize="15" FontWeight="SemiBold" />
|
||||
|
||||
<TextBlock TextWrapping="Wrap" Foreground="#555555"
|
||||
Text="Laufende Worker und Dienste werden gestoppt. Offene Broker-Anfragen werden abgebrochen; bereits platzierte Orders bleiben beim Broker bestehen und werden NICHT storniert." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button x:Name="CancelButton" Content="Abbrechen" IsCancel="True" MinWidth="100" />
|
||||
<Button x:Name="ConfirmButton" Content="Beenden" IsDefault="True" MinWidth="100" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,31 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Sicherheitsabfrage vor dem Beenden. Ersetzt <c>MessageBox.Show</c> – Avalonia bringt keinen
|
||||
/// eingebauten Meldungsdialog mit.
|
||||
///
|
||||
/// <para>Liefert <c>true</c> bei Bestätigung, sonst <c>false</c>; auch das Schließen über das X
|
||||
/// zählt als Abbruch, damit ein versehentlicher Klick nie den Handelsbetrieb stoppt.</para>
|
||||
/// </summary>
|
||||
public partial class ShutdownConfirmWindow : Window
|
||||
{
|
||||
public ShutdownConfirmWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
this.FindControl<Button>("ConfirmButton")!.Click += (_, _) => Close(true);
|
||||
this.FindControl<Button>("CancelButton")!.Click += (_, _) => Close(false);
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
// Wird das Fenster über das X geschlossen, ist kein Ergebnis gesetzt – ShowDialog<bool>
|
||||
// liefert dann default(bool) = false. Genau das ist gewollt.
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
|
||||
xmlns:w="clr-namespace:IBKRTrader.Core.Workers;assembly=IBKRTrader.Core"
|
||||
x:Class="IBKRTrader.App.Avalonia.Views.WorkersWindow"
|
||||
Title="Workers / Services"
|
||||
Width="1200" Height="700"
|
||||
MinWidth="760" MinHeight="420"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<DockPanel>
|
||||
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="TriggerButton" Content="Jetzt ausführen"
|
||||
ToolTip.Tip="Löst den ausgewählten Worker sofort aus." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock x:Name="StatusText" />
|
||||
</Border>
|
||||
|
||||
<!-- Die Zeilen sind WorkerInfo-Objekte aus der WorkerEngine. Sie melden Änderungen über
|
||||
INotifyPropertyChanged, deshalb aktualisiert sich das Raster von selbst. -->
|
||||
<dg:DataGrid x:Name="WorkersGrid" AutoGenerateColumns="False" SelectionMode="Single"
|
||||
x:DataType="w:WorkerInfo">
|
||||
<dg:DataGrid.Columns>
|
||||
<dg:DataGridCheckBoxColumn Header="Aktiv" Binding="{Binding Active}" Width="60" />
|
||||
<dg:DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="80" />
|
||||
<dg:DataGridTextColumn Header="Modul" Binding="{Binding Module}" Width="100" />
|
||||
<dg:DataGridTextColumn Header="Worker" Binding="{Binding WorkerName}" Width="200" />
|
||||
<dg:DataGridTextColumn Header="Letzter Lauf"
|
||||
Binding="{Binding LastRuntime, StringFormat='{}{0:dd.MM.yyyy HH:mm:ss}', TargetNullValue='–'}"
|
||||
Width="150" />
|
||||
<dg:DataGridTextColumn Header="Nächster Lauf"
|
||||
Binding="{Binding NextRuntime, StringFormat='{}{0:dd.MM.yyyy HH:mm:ss}', TargetNullValue='–'}"
|
||||
Width="150" />
|
||||
<dg:DataGridTextColumn Header="Intervall" Binding="{Binding RunEvery}" Width="90" />
|
||||
<dg:DataGridTextColumn Header="Info" Binding="{Binding Info}" Width="*" />
|
||||
</dg:DataGrid.Columns>
|
||||
</dg:DataGrid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,61 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using IBKRTrader.App.Avalonia.Shell;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Time;
|
||||
using IBKRTrader.Core.Workers;
|
||||
|
||||
namespace IBKRTrader.App.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Ansicht: Worker- und Service-Übersicht, live an die <see cref="WorkerEngine"/> gebunden.
|
||||
///
|
||||
/// <para>Die Liste selbst ändert sich zur Laufzeit nicht – die Worker werden einmal im
|
||||
/// Konstruktor der Engine registriert. Was sich ändert, sind die Eigenschaften je Zeile, und
|
||||
/// die meldet <c>WorkerInfo</c> über <c>INotifyPropertyChanged</c>. Deshalb genügt hier die
|
||||
/// direkte Bindung an die Liste der Engine, ohne eine gespiegelte Sammlung.</para>
|
||||
/// </summary>
|
||||
public partial class WorkersWindow : Window
|
||||
{
|
||||
private readonly WorkerEngine _engine;
|
||||
|
||||
public WorkersWindow(IModuleUiHost uiHost, WorkerEngine engine)
|
||||
{
|
||||
_engine = engine;
|
||||
|
||||
InitializeComponent();
|
||||
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.workers");
|
||||
|
||||
var grid = this.FindControl<DataGrid>("WorkersGrid")!;
|
||||
grid.ItemsSource = _engine.WorkerInfos;
|
||||
|
||||
this.FindControl<Button>("TriggerButton")!.Click += async (_, _) => await TriggerSelectedAsync();
|
||||
|
||||
SetStatus($"{_engine.WorkerInfos.Count} Worker/Services registriert.");
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private async Task TriggerSelectedAsync()
|
||||
{
|
||||
if (this.FindControl<DataGrid>("WorkersGrid")!.SelectedItem is not WorkerInfo selected)
|
||||
{
|
||||
SetStatus("Kein Worker ausgewählt.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SetStatus($"{selected.WorkerName} wird ausgelöst …");
|
||||
await _engine.TriggerWorkerAsync(selected.WorkerName);
|
||||
SetStatus($"{selected.WorkerName} ausgelöst.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus($"{selected.WorkerName} fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetStatus(string text) =>
|
||||
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Database": {
|
||||
"MySqlConnectionString": ""
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user