Arbeitsstand gesichert: Deploymentcenter-Integration (D-0 bis D-5) und Einfenster-Shell

Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt,
welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen.

Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json
ueberschneiden und darum gemeinsam abgelegt werden:

Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig):
- Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert
- DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli
- WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt
  (version, os, checks, metrics, status stopped)
- Security: MasterKeyResolver, SecretRedactor, FilePermissions
- Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin)
- deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben
  ueber .gitignore aussen vor
- setup.json fuer die Erstinstallation
- UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md
  verweist auf den neuen Plan

Einfenster-Shell (UI-Redesign):
- ShellWindow + ShellNavModel als Seitenleisten-Shell
- WindowMenuBar und WindowMenuModel entfallen
- Modul- und Kernfenster auf die Shell-Einbettung angepasst

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-22 10:42:22 +02:00
co-authored by Claude Opus 5
parent ac5e16f0a6
commit dd8da3fd3f
58 changed files with 3932 additions and 650 deletions
@@ -1,12 +1,9 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow"
x:DataType="vm:DashboardChartsModel"
Title="Dashboard"
Width="1714" Height="1083">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow"
x:DataType="vm:DashboardChartsModel">
<!--
Modulübergreifendes Dashboard aus dem Core-Trade-Log.
@@ -18,8 +15,6 @@
damit interaktiv (Tooltips, Zoom) und ohne System.Drawing.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnRefresh" Content="Aktualisieren" />
@@ -168,4 +163,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -19,7 +19,7 @@ namespace PolyTrader.App.Avalonia.Views
/// die Diagramme jetzt aus <see cref="DashboardChartsModel"/> (LiveCharts2).
/// Layout vollständig in DashboardWindow.axaml.
/// </summary>
public partial class DashboardWindow : Window
public partial class DashboardWindow : UserControl
{
private readonly DashboardChartsModel _charts = new();
private readonly ObservableCollection<DashboardTradeRow> _history = new();
@@ -43,8 +43,6 @@ namespace PolyTrader.App.Avalonia.Views
public DashboardWindow(IModuleUiHost host, ITradeLogRepository tradeLog, TradingState state,
IReadOnlyList<ModuleActivationInfo>? moduleInfos = null, string? settingsPath = null) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.dashboard", this);
_tradeLog = tradeLog;
_state = state;
_moduleInfos = moduleInfos;
@@ -75,9 +73,10 @@ namespace PolyTrader.App.Avalonia.Views
InitModulesTab();
// Die KPI-Farbe wird im Code gesetzt und wechselt daher nicht von selbst mit dem Thema.
void OnTheme() => ApplyScope();
Shell.ThemeManager.ThemeChanged += OnTheme;
Closed += (_, _) => Shell.ThemeManager.ThemeChanged -= OnTheme;
// Kein Abmelden mehr: Seiten werden in der Einfenster-Shell einmal erzeugt und leben
// bis zum Programmende (siehe AvaloniaUiHost) – ein Gegenstück zu Window.Closed gibt es
// hier nicht, und Unloaded würde bei jedem Seitenwechsel feuern.
Shell.ThemeManager.ThemeChanged += ApplyScope;
}
// ===== Daten laden / Scope =====
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace PolyTrader.App.Avalonia.Views
@@ -7,6 +8,10 @@ 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.
///
/// <para>Seit der Umstellung auf die Einfenster-Shell rufen die Seiten (UserControls) hier auf
/// und können kein <see cref="Window"/> mehr als Besitzer übergeben. Die Helfer nehmen deshalb
/// ein beliebiges <see cref="Control"/> und suchen sich das Fenster selbst.</para>
/// </summary>
public partial class DialogWindow : Window
{
@@ -35,31 +40,48 @@ namespace PolyTrader.App.Avalonia.Views
cancel.Click += (_, _) => Close(false);
}
/// <summary>Reiner Hinweis mit OK.</summary>
public static async Task Info(Window owner, string title, string message)
/// <summary>
/// Fenster zum aufrufenden Steuerelement. Fällt auf das Hauptfenster zurück, falls das
/// Steuerelement (noch) nicht im Baum hängt – etwa während der Konstruktionsprüfung.
/// </summary>
private static Window? OwnerOf(Control owner)
{
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: false, masked: false, okText: "OK", withCancel: false);
await dlg.ShowDialog(owner);
if (TopLevel.GetTopLevel(owner) is Window window) return window;
return (global::Avalonia.Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
}
/// <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")
/// <summary>Reiner Hinweis mit OK.</summary>
public static async Task Info(Control owner, string title, string message)
{
if (OwnerOf(owner) is not { } window) return;
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: false, masked: false, okText: "OK", withCancel: false);
await dlg.ShowDialog(window);
}
/// <summary>
/// Ja/Nein-Rückfrage. Liefert <c>true</c> bei Bestätigung – und <c>false</c>, wenn sich gar
/// kein Fenster finden lässt: ohne Oberfläche gilt nichts als bestätigt.
/// </summary>
public static async Task<bool> Confirm(Control owner, string title, string message, string okText = "Ja")
{
if (OwnerOf(owner) is not { } window) return false;
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: false, masked: false, okText: okText, withCancel: true);
return await dlg.ShowDialog<bool>(owner);
return await dlg.ShowDialog<bool>(window);
}
/// <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)
public static async Task<string?> Prompt(Control owner, string title, string message, bool masked = true)
{
if (OwnerOf(owner) is not { } window) return null;
var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: true, masked: masked, okText: "OK", withCancel: true);
bool ok = await dlg.ShowDialog<bool>(owner);
bool ok = await dlg.ShowDialog<bool>(window);
return ok ? dlg.FindControl<TextBox>("txtInput")!.Text ?? string.Empty : null;
}
}
@@ -1,10 +1,7 @@
<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.JobsWindow"
Title="Server Jobs"
Width="1429" Height="833">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.JobsWindow">
<!--
Ansicht der Hintergrund-Jobs (JobManager).
@@ -14,8 +11,6 @@
auf anderen Auflösungen mitwächst — die Proportionen bleiben erhalten.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal">
<Button Name="btnRefresh" Content="Aktualisieren" />
@@ -51,4 +46,4 @@
</DataGrid>
</DockPanel>
</Window>
</UserControl>
@@ -12,14 +12,16 @@ namespace PolyTrader.App.Avalonia.Views
/// Hintergrund-Jobs (JobManager). Verhalten wie bisher: „Run Now" löst den ManualTrigger aus,
/// „Aktiv" schaltet den Job über die Bindung. Layout vollständig in JobsWindow.axaml.
/// </summary>
public partial class JobsWindow : Window
public partial class JobsWindow : UserControl
{
public JobsWindow() => AvaloniaXamlLoader.Load(this);
/// <param name="host">
/// Wird für diese Seite nicht mehr gebraucht (die Navigation sitzt in der Shell), bleibt
/// aber im Vertrag, weil <c>ModuleView.CreateView</c> alle Seiten gleich erzeugt.
/// </param>
public JobsWindow(IModuleUiHost host, JobManager jobManager) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.jobs", this);
var grid = this.FindControl<DataGrid>("gridJobs")!;
grid.ItemsSource = jobManager.Jobs;
@@ -1,21 +1,19 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.LauncherWindow"
Title="PolyTrader"
Width="1600" Height="900"
WindowState="Maximized">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.LauncherWindow">
<!--
„Startleiste" der Anwendung. Entspricht der bisherigen LauncherForm:
oben die gemeinsame Fenster-Menüleiste, darunter die Schnellaktionen (Trading-Modi),
dann die Fenster-Buttons, Account-Übersicht, Modul-KPIs und Live-Überblick-Widgets, unten die Statusleiste.
Startseite der Anwendung. Entspricht der bisherigen LauncherForm, ist seit der Umstellung
auf die Einfenster-Shell aber eine Seite unter vielen: oben die Schnellaktionen
(Trading-Modi), dann die Kacheln zu den übrigen Ansichten, Account-Übersicht, Modul-KPIs
und die Live-Überblick-Widgets.
Die frühere Statusleiste (Trading / Module / API) steht jetzt einmal zentral in
Views/ShellWindow.axaml und nicht mehr auf dieser Seite.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Name="btnLiveTrading" Content="LiveTrading (—)" MinWidth="170" />
@@ -23,17 +21,25 @@
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<StackPanel Orientation="Horizontal" Spacing="24">
<TextBlock Name="lblTrading" Text="Trading: —" />
<TextBlock Name="lblModules" Text="Module: —" />
<TextBlock Name="lblRatelimit" Text="API: —" />
</StackPanel>
<!--
D-4: Update-Hinweis. Verborgen, solange keine Prüfung lief oder keine neue Version
vorliegt - erscheint nur, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist UND
tatsächlich ein Update gefunden wurde. Installiert NIE automatisch (Leitentscheidung 4).
-->
<Border Name="pnlUpdateBanner" DockPanel.Dock="Top" IsVisible="False"
Background="{DynamicResource AppWarningBrush}" Padding="10,6">
<DockPanel>
<StackPanel Orientation="Horizontal" Spacing="10" DockPanel.Dock="Right">
<Button Name="btnInstallUpdate" Content="Jetzt installieren …" />
<Button Name="btnDismissUpdate" Content="Später" />
</StackPanel>
<TextBlock Name="lblUpdateInfo" Text="—" VerticalAlignment="Center" TextWrapping="Wrap" />
</DockPanel>
</Border>
<ScrollViewer>
<StackPanel Margin="12" Spacing="16">
<TextBlock Text="Fenster" FontSize="14" FontWeight="SemiBold" />
<StackPanel Margin="0,14,0,0" Spacing="16">
<TextBlock Text="ANSICHTEN" Classes="kicker" />
<ItemsControl Name="windowButtons">
<ItemsControl.ItemsPanel>
@@ -42,7 +48,7 @@
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shell:WindowMenuEntry">
<DataTemplate x:DataType="shell:ViewTile">
<Button Command="{Binding Command}"
MinWidth="190" MinHeight="56"
Margin="0,0,8,8"
@@ -156,4 +162,4 @@
</ScrollViewer>
</DockPanel>
</Window>
</UserControl>
@@ -3,17 +3,22 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Deploymentcenter.Client;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core;
using PolyTrader.Core.Analytics;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
@@ -22,17 +27,23 @@ using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// „Startleiste" der Anwendung – Gegenstück zur bisherigen LauncherForm. Öffnet die Fenster,
/// spiegelt deren Offen-Status, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets.
/// Startseite der Anwendung – Gegenstück zur bisherigen LauncherForm. Führt zu den übrigen
/// Ansichten, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets.
/// Layout vollständig in LauncherWindow.axaml.
///
/// <para>Seit der Umstellung auf die Einfenster-Shell ist dies ein <see cref="UserControl"/>
/// und kein Fenster mehr; der Klassenname bleibt vorerst, um die parallele
/// Deploymentcenter-Arbeit an dieser Datei nicht mit einer Umbenennung zu kollidieren.
/// Die Statusleiste (Trading/Module/API) und das Beenden liegen jetzt in
/// <see cref="ShellWindow"/>.</para>
/// </summary>
public partial class LauncherWindow : Window
public partial class LauncherWindow : UserControl
{
private readonly AvaloniaUiHost _uiHost;
private readonly IModuleUiHost _uiHost;
private readonly IServiceProvider _services;
private readonly TradingState _state;
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly ObservableCollection<WindowMenuEntry> _windowButtons = new();
private readonly ObservableCollection<ViewTile> _windowButtons = new();
private readonly ObservableCollection<AccountOverviewRow> _accountRows = new();
private readonly ObservableCollection<ModuleKpiTile> _kpiTiles = new();
private readonly ObservableCollection<NotableTradeRow> _notableRows = new();
@@ -40,17 +51,17 @@ namespace PolyTrader.App.Avalonia.Views
private int _statusTicks;
/// <summary>Ergebnis der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
private UpdateCheckResult? _updateCheck;
public LauncherWindow() => AvaloniaXamlLoader.Load(this);
public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider services) : this()
public LauncherWindow(IModuleUiHost uiHost, IServiceProvider services) : this()
{
_uiHost = uiHost;
_services = services;
_state = services.GetRequiredService<TradingState>();
// currentViewId == null => dies ist der Launcher (rechte Menueaktion "Beenden").
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(uiHost, null, this);
this.FindControl<ItemsControl>("windowButtons")!.ItemsSource = _windowButtons;
this.FindControl<DataGrid>("gridAccounts")!.ItemsSource = _accountRows;
this.FindControl<ItemsControl>("kpiTiles")!.ItemsSource = _kpiTiles;
@@ -71,27 +82,24 @@ namespace PolyTrader.App.Avalonia.Views
UpdateTradingToggles();
};
this.FindControl<Button>("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
this.FindControl<Button>("btnDismissUpdate")!.Click += (_, _) =>
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = false;
_statusTimer.Tick += (_, _) => UpdateStatus();
_statusTimer.Start();
UpdateStatus();
RefreshData();
// Schliessen des Launchers laeuft ueber dieselbe Sicherheitsabfrage wie "Beenden".
Closing += (_, e) =>
{
if (_uiHost.ShutdownConfirmed) return;
e.Cancel = true;
Dispatcher.UIThread.Post(_uiHost.RequestShutdown);
};
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
ThemeManager.ThemeChanged += UpdateTradingToggles;
// D-4: einmalige, stille Prüfung beim Start - fire-and-forget, darf den Start nie
// verzögern oder blockieren. Nur aktiv, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist.
_ = CheckForUpdateAsync();
Closed += (_, _) =>
{
_statusTimer.Stop();
_uiHost.OpenStateChanged -= OnOpenStateChanged;
ThemeManager.ThemeChanged -= UpdateTradingToggles;
};
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
// Kein Abmelden: die Seite lebt in der Einfenster-Shell bis zum Programmende, ein
// Gegenstueck zu Window.Closed gibt es nicht (Sicherheitsabfrage und Beenden liegen
// jetzt im ShellWindow).
ThemeManager.ThemeChanged += UpdateTradingToggles;
}
/// <summary>Dreistufiger Wechsel wie bisher: Inactive -> SellOnly -> Active -> Inactive.</summary>
@@ -108,14 +116,20 @@ namespace PolyTrader.App.Avalonia.Views
else Dispatcher.UIThread.Post(RebuildWindowButtons);
}
/// <summary>
/// Kacheln zu den übrigen Ansichten. Die Startseite selbst bleibt aussen vor – sie ist ja
/// gerade zu sehen; ihre Kachel würde nur ins Leere führen und dauerhaft als „offen" gelten.
/// </summary>
private void RebuildWindowButtons()
{
_windowButtons.Clear();
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
foreach (var view in _uiHost.Views
.Where(v => v.Id != AvaloniaUiHost.StartViewId)
.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
string id = view.Id;
bool open = _uiHost.IsOpen(id);
_windowButtons.Add(new WindowMenuEntry
_windowButtons.Add(new ViewTile
{
Title = view.Title,
Icon = ViewIcons.Resolve(view.IconKey),
@@ -144,19 +158,12 @@ namespace PolyTrader.App.Avalonia.Views
}
}
/// <summary>
/// Hält die Umschalter und die Widgets frisch. Die frühere Statusleiste dieser Seite ist
/// in die Shell gewandert und wird dort im eigenen Takt aktualisiert.
/// </summary>
private void UpdateStatus()
{
string trading = _state.GlobalTradingPaused
? "Pausiert"
: $"Live={_state.LiveTradingMode} / Demo={_state.DemoTradingMode}";
this.FindControl<TextBlock>("lblTrading")!.Text = $"Trading: {trading}";
int moduleCount = _services.GetServices<IPolyTraderModule>().Count();
this.FindControl<TextBlock>("lblModules")!.Text = $"Module: {moduleCount}";
this.FindControl<TextBlock>("lblRatelimit")!.Text =
$"API: {(_state.IsAlchemyHealthy ? "WSS aktiv" : "Polling")}";
UpdateTradingToggles();
// Account-Übersicht + Widgets alle 30 s aktualisieren
@@ -175,6 +182,110 @@ namespace PolyTrader.App.Avalonia.Views
UpdateSupervisor();
}
// ===== Update-Prüfung (D-4) =====
/// <summary>
/// Prüft beim Start still auf ein neues Release. Installiert NIE automatisch (Leitentscheidung
/// 4 aus dem Umsetzungsplan) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das
/// Verhalten. Wirft nie: ein nicht erreichbares Deploymentcenter darf den Start nicht stören.
/// </summary>
private async Task CheckForUpdateAsync()
{
try
{
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null || !dcOptions.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dcOptions.BaseUrl))
return;
var settings = _services.GetService<ServerSettings>();
string? licenseKey = null;
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* nicht lesbarer Schlüssel -> Prüfung läuft ohne Zugangsdaten weiter */ }
var updateClient = new UpdateClient();
var result = await updateClient.CheckForUpdateAsync(
baseUrl: dcOptions.BaseUrl,
projectId: dcOptions.ProjectSlug,
currentVersion: BuildInfo.Version,
channel: dcOptions.Channel,
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
if (result.Unauthorized)
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr für den Paketabruf. Kein
// Alarm im Launcher nötig - die Lizenzprüfung selbst (D-2) hat das schon gemeldet.
return;
}
if (!result.UpdateAvailable || result.LatestRelease == null) return;
_updateCheck = result;
string version = result.LatestRelease.Version;
string critical = result.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
Dispatcher.UIThread.Post(() =>
{
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
$"Update verfügbar: v{version}{critical}. {result.LatestRelease.Changelog}".TrimEnd();
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = true;
});
}
catch
{
// Update-Prüfung darf den Start nie stören - stiller Rückzug, kein Log-Spam beim
// vermutlich häufigsten Fall (Deploymentcenter nicht erreichbar).
}
}
/// <summary>
/// Startet den Update-Agent nach Bestätigung. Läuft über denselben geordneten Shutdown-Weg
/// wie „Beenden" (inklusive der 10-Sekunden-Sicherheitsabfrage) - <c>exitCurrentApp: false</c>
/// ist hier Pflicht, kein Environment.Exit bei offenen Positionen (Leitentscheidung 3).
/// </summary>
private async Task InstallUpdateAsync()
{
if (_updateCheck?.LatestRelease == null) return;
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null) return;
string? agentPath = UpdateClient.ResolveAgentPath();
if (agentPath == null)
{
await DialogWindow.Info(this, "Update",
"Kein Update-Agent im Installationsverzeichnis gefunden. Diese Installation kann " +
"sich nicht selbst aktualisieren - bitte das neue Paket von Hand einspielen.");
return;
}
bool confirmed = await DialogWindow.Confirm(this, "Update installieren",
$"Version {_updateCheck.LatestRelease.Version} herunterladen und PolyTrader danach " +
"neu starten?\n\nOffene Vorgänge werden dabei genauso geordnet beendet wie über „Beenden\".",
"Installieren");
if (!confirmed) return;
var settings = _services.GetService<ServerSettings>();
string? licenseKey = null;
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* siehe CheckForUpdateAsync */ }
UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: dcOptions.ProjectSlug,
channel: dcOptions.Channel,
action: "update",
version: "latest",
currentVersion: BuildInfo.Version,
exitCurrentApp: false,
waitForCurrentProcess: true,
waitTimeoutSeconds: 40,
licenseKey: licenseKey);
// Derselbe Weg wie ein Klick auf "Beenden" - inklusive Sicherheitsabfrage. Der Agent
// wartet ohnehin per --wait-for-pid auf unser Prozessende.
_uiHost.RequestShutdown();
}
// ===== Account-Übersicht =====
private void LoadAccountOverview()
@@ -1,17 +1,12 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:acc="using:PolyTrader.Modules.Accounting.Models"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow"
Title="Accounting"
Width="1571" Height="1067">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:acc="using:PolyTrader.Modules.Accounting.Models"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow">
<!-- Drei Tabs (Übersicht/BWA, Ledger, Abruf/Status), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md. KPI-Kacheln zweizeilig wie zuvor. -->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblStatus" Text="Bereit." />
</Border>
@@ -164,4 +159,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -21,7 +21,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// Accounting: Periodenabrechnung mit KPI-Kacheln und Monatsübersicht, Ledger-Ansicht und
/// Abruf-/Status-Tab. Layout vollständig in AccountingWindow.axaml.
/// </summary>
public partial class AccountingWindow : Window
public partial class AccountingWindow : UserControl
{
private readonly ObservableCollection<MonthlyRow> _monthly = new();
private readonly ObservableCollection<LedgerEntry> _ledger = new();
@@ -43,8 +43,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
ILedgerRepository ledgerRepo, IIngestRunRepository runRepo,
AccountingIngestService ingest, TerminalLogger logger) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "accounting.main", this);
_state = state;
_reports = reports;
_ledgerRepo = ledgerRepo;
@@ -157,7 +155,12 @@ namespace PolyTrader.App.Avalonia.Views.Modules
try
{
var picker = await StorageProvider.SaveFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerSaveOptions
// Seit der Umstellung auf Seiten ist „this" kein TopLevel mehr – der Dateidialog
// gehört dem Shell-Fenster und wird über den Baum aufgelöst.
var storage = TopLevel.GetTopLevel(this)?.StorageProvider;
if (storage == null) { Status("Kein Fenster für den Dateidialog verfügbar."); return; }
var picker = await storage.SaveFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerSaveOptions
{
Title = pdf ? "PDF-Export" : "CSV-Export",
SuggestedFileName = $"abrechnung_{_statement.From:yyyyMMdd}_{_statement.To:yyyyMMdd}" + (pdf ? ".pdf" : ".csv"),
@@ -1,11 +1,9 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:ct="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow"
Title="Copytrading"
Width="1714" Height="1167">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:ct="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow">
<!--
Ein Fenster fürs ganze Modul, vier Tabs – Aufbau nach docs/UI-SPEZIFIKATION-WinForms.md.
@@ -13,8 +11,6 @@
Pixelbreiten sind in Sternbreiten übersetzt, damit das Fenster mitwächst.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl>
<TabItem Header="Master-Trader">
@@ -160,4 +156,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -17,7 +17,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// Copytrading-Fenster mit vier Tabs: Master-Trader, Offene Trades, Geschlossene Trades,
/// Account-Einstellungen. Layout vollständig in CopyTradingWindow.axaml.
/// </summary>
public partial class CopyTradingWindow : Window
public partial class CopyTradingWindow : UserControl
{
private readonly ObservableCollection<TrackedTrader> _traders = new();
private readonly ObservableCollection<AccountAssignment> _assignments = new();
@@ -39,8 +39,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
ITrackedTraderRepository traderRepo, ICopyTradingAccountSettingsRepository settingsRepo,
ICopyTradeLogRepository tradeLog) : this()
{
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "copytrading.main", this);
_state = state;
_copyState = copyState;
_traderRepo = traderRepo;
@@ -103,8 +101,9 @@ namespace PolyTrader.App.Avalonia.Views.Modules
gridOpen.ItemsSource = null; gridOpen.ItemsSource = _openTrades;
gridClosed.ItemsSource = null; gridClosed.ItemsSource = _closedTrades;
}
// Kein Abmelden: Seiten leben in der Einfenster-Shell bis zum Programmende
// (siehe AvaloniaUiHost), ein Gegenstueck zu Window.Closed gibt es nicht.
Shell.ThemeManager.ThemeChanged += OnTheme;
Closed += (_, _) => Shell.ThemeManager.ThemeChanged -= OnTheme;
}
// ===== Master-Trader =====
@@ -1,16 +1,12 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow"
Title="ResolutionFarming"
Width="1429" Height="1030">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow">
<!-- Vier Tabs (Kandidaten / Positionen / Historie+Statistik / Settings), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md: je Tab ein Grid mit „Aktualisieren", Konto-Auswahl oben. -->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" />
@@ -94,4 +90,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -15,7 +15,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// ResolutionFarming: Kandidaten, offene Positionen, Historie und Einstellungen je Konto.
/// Layout vollständig in ResolutionFarmingWindow.axaml.
/// </summary>
public partial class ResolutionFarmingWindow : Window
public partial class ResolutionFarmingWindow : UserControl
{
private readonly ObservableCollection<RfCandidate> _candidates = new();
private readonly ObservableCollection<RfPosition> _positions = new();
@@ -35,8 +35,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
IRfCandidateRepository candidateRepo, IRfPositionRepository positionRepo,
IRfClosedTradeRepository closedRepo, IRfSettingsRepository settingsRepo) : this()
{
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "resolutionfarming.main", this);
_state = state;
_candidateRepo = candidateRepo;
_positionRepo = positionRepo;
@@ -1,20 +1,15 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence"
xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual"
xmlns:svc="using:PolyTrader.Modules.Supervisor.Services"
x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow"
Title="Supervisor"
Width="1786" Height="1163">
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence"
xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual"
xmlns:svc="using:PolyTrader.Modules.Supervisor.Services"
x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow">
<!-- Vier Tabs (Analyse / Dossiers / Berichte / Counterfactual), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md. Der Analyse-Tab ist chat-artig: Verlauf oben,
Eingabe unten, Profil- und Modellauswahl in der Werkzeugleiste. -->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl>
<TabItem Header="Analyse">
@@ -126,4 +121,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -19,7 +19,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// Supervisor: Analyse-Chat gegen den Agenten, Dossiers je Signal, gespeicherte Berichte und
/// Counterfactuals. Layout vollständig in SupervisorWindow.axaml.
/// </summary>
public partial class SupervisorWindow : Window
public partial class SupervisorWindow : UserControl
{
private readonly ObservableCollection<ChatEntry> _chat = new();
private readonly ObservableCollection<SignalSummary> _signals = new();
@@ -38,8 +38,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
ISupervisorReportRepository reportRepo, ISupervisorCounterfactualRepository cfRepo,
TerminalLogger logger) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "supervisor.main", this);
_agent = agent;
_dossiers = dossiers;
_reportRepo = reportRepo;
@@ -1,10 +1,8 @@
<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">
<UserControl 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">
<!--
Einstellungen. Ersetzt die beiden PropertyGrids der WinForms-Fassung durch den
@@ -12,8 +10,6 @@
Erklärung klein darunter).
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblStatus" Text="Bereit." />
</Border>
@@ -35,6 +31,11 @@
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." />
<Separator Margin="6,0" />
<Button Name="btnLicenseKey" Content="Lizenzschlüssel setzen …"
ToolTip.Tip="Speichert den Deploymentcenter-Lizenzschlüssel (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt)." />
<Button Name="btnCheckLicense" Content="Lizenz jetzt prüfen"
ToolTip.Tip="Prüft den gespeicherten Lizenzschlüssel gegen das Deploymentcenter und zeigt Status sowie Hardware-ID." />
</StackPanel>
</Border>
@@ -72,4 +73,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -2,11 +2,13 @@ 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 Deploymentcenter.Client;
using PolyTrader.App.Avalonia.Controls;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
@@ -21,7 +23,7 @@ namespace PolyTrader.App.Avalonia.Views
/// den <see cref="SettingsEditor"/> anstelle der früheren PropertyGrids.
/// Layout vollständig in SettingsWindow.axaml.
/// </summary>
public partial class SettingsWindow : Window
public partial class SettingsWindow : UserControl
{
private const string SettingsPath = "server_settings.xml";
@@ -33,20 +35,20 @@ namespace PolyTrader.App.Avalonia.Views
private WatchdogHeartbeatService? _watchdog;
private MullvadVpnService? _vpn;
private TerminalLogger? _logger;
private DeploymentcenterOptions? _dcOptions;
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
MullvadVpnService? vpn = null) : this()
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.settings", this);
_accountRepo = accountRepo;
_state = state;
_logger = logger;
_watchdog = watchdog;
_vpn = vpn;
_dcOptions = dcOptions;
var grid = this.FindControl<DataGrid>("gridAccounts")!;
grid.ItemsSource = _accounts;
@@ -59,6 +61,8 @@ namespace PolyTrader.App.Avalonia.Views
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>("btnLicenseKey")!.Click += async (_, _) => await SetLicenseKeyAsync();
this.FindControl<Button>("btnCheckLicense")!.Click += async (_, _) => await CheckLicenseAsync();
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
@@ -199,19 +203,8 @@ namespace PolyTrader.App.Avalonia.Views
/// 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).");
}
}
private void RestrictToOwner(string path) =>
FilePermissions.RestrictToOwner(path, msg => _logger?.Warning($"⚠️ {msg}"));
// ===== OpenRouter-API-Key (Supervisor) =====
@@ -323,5 +316,111 @@ namespace PolyTrader.App.Avalonia.Views
btn.IsEnabled = true;
}
}
// ===== Lizenz (D-2) =====
private async System.Threading.Tasks.Task SetLicenseKeyAsync()
{
bool exists = !string.IsNullOrWhiteSpace(_settings.LicenseKey);
string? key = await DialogWindow.Prompt(this, "Deploymentcenter-Lizenzschlüssel",
"Lizenzschlüssel eingeben.\n" +
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
"Wirkt erst beim nächsten Start von PolyTrader (keine Laufzeit-Revalidierung).\n" +
(exists ? "Aktuell ist ein Schlüssel gesetzt." : "Aktuell ist KEIN Schlüssel gesetzt."));
if (key == null) return; // Abbruch
try
{
_settings.LicenseKey = string.IsNullOrWhiteSpace(key)
? string.Empty
: SecretProtection.Protect(key.Trim());
_settings.Save(SettingsPath);
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
if (_settings.LicenseKey.Length == 0)
{
_logger?.Info("Lizenzschlüssel entfernt.");
Status("Lizenzschlüssel entfernt – PolyTrader startet beim nächsten Mal im eingeschränkten Modus.");
return;
}
bool encrypted = SecretProtection.IsEncrypted(_settings.LicenseKey);
_logger?.Info($"Lizenzschlüssel gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
Status((encrypted
? "Lizenzschlüssel gespeichert (verschlüsselt). "
: "Lizenzschlüssel gespeichert – ACHTUNG: ohne Master-Key im Klartext. ") +
"Mit „Lizenz jetzt prüfen\" testen, wirkt für Module erst beim nächsten Start.");
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des Lizenzschlüssels: {ex.Message}");
}
}
/// <summary>
/// Prüft den GESPEICHERTEN Schlüssel gegen das Deploymentcenter und zeigt Urteil und
/// Hardware-ID (zum Kopieren, falls Richard sie für eine Aktivierung im Admin braucht).
/// Ungespeicherte Änderungen wirken bewusst nicht, aus demselben Grund wie beim Watchdog-Test.
/// </summary>
private async System.Threading.Tasks.Task CheckLicenseAsync()
{
if (_dcOptions == null) return;
var btn = this.FindControl<Button>("btnCheckLicense")!;
btn.IsEnabled = false;
try
{
if (string.IsNullOrWhiteSpace(_dcOptions.BaseUrl))
{
await DialogWindow.Info(this, "Lizenz", "Keine Deploymentcenter-URL konfiguriert (appsettings.json).");
return;
}
string key;
try { key = SecretProtection.Unprotect(_settings.LicenseKey); }
catch (Exception ex)
{
await DialogWindow.Info(this, "Lizenz", $"Gespeicherter Schlüssel nicht lesbar: {ex.Message}");
return;
}
var hw = HardwareId.GetHardwareId(_dcOptions.ProjectSlug);
if (string.IsNullOrWhiteSpace(key))
{
await DialogWindow.Info(this, "Lizenz",
$"Kein Lizenzschlüssel gesetzt.\n\nHardware-ID dieser Maschine:\n{hw.HardwareId}");
Status("Kein Lizenzschlüssel gesetzt.");
return;
}
Status("Prüfe Lizenz …");
LicenseClient.DefaultAppVersion = BuildInfo.Version;
var client = new LicenseClient();
var result = await client.ValidateAsync(_dcOptions.ProjectSlug, key, _dcOptions.BaseUrl, BuildInfo.Version);
string detail = $"Status: {result.Status}\n{result.Message}\n\nHardware-ID dieser Maschine:\n{hw.HardwareId}";
if (result.IsValid)
{
_logger?.Info($"🔑 Lizenz geprüft: gültig ({result.Status}).");
Status($"Lizenz gültig ({result.Status}).");
await DialogWindow.Info(this, "Lizenz gültig", detail);
}
else
{
_logger?.Warning($"⚠️ Lizenz geprüft: nicht gültig ({result.Status}).");
Status($"Lizenz NICHT gültig ({result.Status}).");
await DialogWindow.Info(this, "Lizenz nicht gültig", detail);
}
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Lizenzprüfung fehlgeschlagen: {ex.Message}");
}
finally
{
btn.IsEnabled = true;
}
}
}
}
@@ -0,0 +1,194 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
x:Class="PolyTrader.App.Avalonia.Views.ShellWindow"
x:DataType="shell:ShellNavModel"
Title="PolyTrader"
Width="1600" Height="900"
WindowState="Maximized">
<!--
Das einzige Anwendungsfenster. Alle Ansichten (Startseite, Dashboard, Modul-Seiten,
Einstellungen, Jobs, Terminal) sind UserControls und werden in „pageHost" gehängt –
Nachfolger der früheren Mehrfenster-Shell mit WindowMenuBar auf jedem Fenster.
Aufbau nach dem Claude-Design-Mockup „PolyTrader UI Redesign":
· links eine einklappbare Seitenleiste mit gruppierter Navigation
· oben Kopfzeile mit Seitentitel und Farbschema-Umschalter
· darunter die Benachrichtigungsleiste (Warnungen/Fehler, wegklickbar)
· unten die Statusleiste (Trading, Module, API, Farbschema)
Layout vollständig deklarativ (Layout-Regel des Projekts). Das Code-Behind verdrahtet nur:
Seitenwechsel, Umschalter, Statuswerte und die Benachrichtigungen.
-->
<Grid ColumnDefinitions="Auto,*">
<!-- ══ Seitenleiste ══════════════════════════════════════════════════════════ -->
<Border Grid.Column="0"
Width="{Binding SidebarWidth}"
Background="{DynamicResource AppSurfaceBrush}"
BorderBrush="{DynamicResource AppDividerBrush}"
BorderThickness="0,0,1,0">
<DockPanel>
<!-- Marke + Einklappen -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource AppDividerBrush}"
BorderThickness="0,0,0,1"
Padding="12,11">
<DockPanel>
<Button DockPanel.Dock="Right"
Name="btnCollapse"
Classes="ghost"
Content="{Binding CollapseGlyph}"
ToolTip.Tip="{Binding CollapseToolTip}"
Width="22" Height="22" Padding="0"
VerticalAlignment="Center"
HorizontalContentAlignment="Center" />
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<!-- Bildmarke des Mockups: aufsteigende Kurve mit Pfeilecke. -->
<Viewbox Width="19" Height="19" VerticalAlignment="Center">
<Canvas Width="24" Height="24">
<Path Data="M22 7 L13.5 15.5 L8.5 10.5 L2 17"
Stroke="{DynamicResource AppAccentBrush}" StrokeThickness="1.6" />
<Path Data="M16 7 H22 V13"
Stroke="{DynamicResource AppAccentBrush}" StrokeThickness="1.6" />
</Canvas>
</Viewbox>
<TextBlock Text="PolyTrader" Classes="h5"
VerticalAlignment="Center"
IsVisible="{Binding LabelsVisible}" />
</StackPanel>
</DockPanel>
</Border>
<!-- Fußzeile: Beenden + Version -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{DynamicResource AppDividerBrush}"
BorderThickness="0,1,0,0"
Padding="10,8">
<StackPanel Spacing="6">
<Button Name="btnQuit" Classes="nav"
Classes.icononly="{Binding !LabelsVisible}"
ToolTip.Tip="PolyTrader beenden (mit Sicherheitsabfrage)">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="⏻" FontSize="14" VerticalAlignment="Center" />
<TextBlock Text="Beenden" VerticalAlignment="Center"
IsVisible="{Binding LabelsVisible}" />
</StackPanel>
</Button>
<TextBlock Name="lblVersion" Classes="muted" FontSize="10.5"
Margin="9,0,0,0"
IsVisible="{Binding LabelsVisible}" />
</StackPanel>
</Border>
<!-- Navigation: Abschnitte, darin die Seiten -->
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Groups}" Margin="8,14">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shell:ShellNavGroup">
<StackPanel Margin="0,0,0,16">
<TextBlock Text="{Binding Title}"
Classes="navgroup"
IsVisible="{Binding LabelsVisible}" />
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shell:ShellNavEntry">
<Button Classes="nav"
Classes.active="{Binding IsActive}"
Classes.icononly="{Binding IconOnly}"
IsEnabled="{Binding IsAvailable}"
Command="{Binding Command}"
ToolTip.Tip="{Binding ToolTip}"
ToolTip.ShowOnDisabled="True">
<StackPanel Orientation="Horizontal" Spacing="10">
<Image Source="{Binding Icon}"
Width="18" Height="18"
VerticalAlignment="Center"
IsVisible="{Binding Icon, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Text="{Binding Title}"
VerticalAlignment="Center"
IsVisible="{Binding LabelsVisible}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- ══ Hauptbereich ══════════════════════════════════════════════════════════ -->
<DockPanel Grid.Column="1">
<!-- Kopfzeile -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource AppDividerBrush}"
BorderThickness="0,0,0,1"
Padding="20,12">
<DockPanel>
<Button DockPanel.Dock="Right"
Name="btnTheme"
Classes="ghost"
Width="30" Height="30" Padding="0"
HorizontalContentAlignment="Center"
VerticalAlignment="Center" />
<TextBlock Text="{Binding PageTitle}" Classes="h4" VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Benachrichtigungen (nur sichtbar, wenn welche anliegen) -->
<Border Name="pnlNotifications" DockPanel.Dock="Top"
IsVisible="False"
BorderBrush="{DynamicResource AppDividerBrush}"
BorderThickness="0,0,0,1">
<ItemsControl Name="notifications">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shell:ShellNotification">
<Border Classes="notify" Classes.error="{Binding IsError}" Margin="0,0,0,1">
<DockPanel>
<Button DockPanel.Dock="Right"
Classes="ghost"
Content="✕" FontSize="11"
Width="20" Height="20" Padding="0"
HorizontalContentAlignment="Center"
Command="{Binding Dismiss}"
ToolTip.Tip="Meldung ausblenden" />
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{Binding Source}" FontWeight="SemiBold" FontSize="12" />
<TextBlock Text="{Binding Message}" FontSize="12"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Statusleiste -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<DockPanel>
<TextBlock Name="lblTheme" DockPanel.Dock="Right" Text="—" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Name="lblTrading" Text="Trading: —" />
<TextBlock Text="·" />
<TextBlock Name="lblModules" Text="Module: —" />
<TextBlock Text="·" />
<TextBlock Name="lblRatelimit" Text="API: —" />
</StackPanel>
</DockPanel>
</Border>
<!-- Inhalt: hier hängt die aktive Seite -->
<ContentControl Name="pageHost" Margin="20,16" />
</DockPanel>
</Grid>
</Window>
@@ -0,0 +1,188 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.Core;
using PolyTrader.Core.Modularity;
using PolyTraderSharp;
using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// Das einzige Anwendungsfenster. Hält die Seitenleiste, die Kopf-, Benachrichtigungs- und
/// Statusleiste und den Inhaltsbereich, in dem die Seiten hängen.
///
/// <para>Ersetzt die frühere Mehrfenster-Shell: statt je View ein <see cref="Window"/> mit
/// eigener Menüleiste gibt es hier eine Navigation und einen Inhaltsbereich. Den Seitenwechsel
/// steuert <see cref="AvaloniaUiHost"/>, das Fenster stellt dafür nur
/// <see cref="ShowPage"/> bereit.</para>
///
/// <para>Layout vollständig in ShellWindow.axaml.</para>
/// </summary>
public partial class ShellWindow : Window
{
/// <summary>Mehr als so viele Meldungen gleichzeitig würden den Inhalt zu weit nach unten drücken.</summary>
private const int MaxNotifications = 3;
private readonly AvaloniaUiHost _uiHost = null!;
private readonly IServiceProvider _services = null!;
private readonly TradingState _state = null!;
private readonly ShellNavModel _nav = null!;
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly ObservableCollection<ShellNotification> _notifications = new();
private TerminalLogger? _logger;
private EventHandler<LogMessageEventArgs>? _logHandler;
public ShellWindow() => AvaloniaXamlLoader.Load(this);
public ShellWindow(AvaloniaUiHost uiHost, IServiceProvider services) : this()
{
_uiHost = uiHost;
_services = services;
_state = services.GetRequiredService<TradingState>();
_nav = new ShellNavModel(uiHost);
DataContext = _nav;
this.FindControl<ItemsControl>("notifications")!.ItemsSource = _notifications;
_notifications.CollectionChanged += (_, _) =>
this.FindControl<Border>("pnlNotifications")!.IsVisible = _notifications.Count > 0;
this.FindControl<TextBlock>("lblVersion")!.Text = $"PolyTrader {BuildInfo.Version} · Avalonia";
this.FindControl<Button>("btnCollapse")!.Click += (_, _) => _nav.ToggleSidebar();
this.FindControl<Button>("btnQuit")!.Click += (_, _) => _uiHost.RequestShutdown();
this.FindControl<Button>("btnTheme")!.Click += (_, _) => ThemeManager.Toggle();
ThemeManager.ThemeChanged += UpdateThemeControls;
UpdateThemeControls();
SubscribeNotifications();
_statusTimer.Tick += (_, _) => UpdateStatus();
_statusTimer.Start();
UpdateStatus();
// Das X des Fensters beendet die Anwendung – deshalb über dieselbe Sicherheitsabfrage
// wie „Beenden" (Verhalten der früheren LauncherForm).
Closing += (_, e) =>
{
if (_uiHost.ShutdownConfirmed) return;
e.Cancel = true;
Dispatcher.UIThread.Post(_uiHost.RequestShutdown);
};
Closed += (_, _) =>
{
_statusTimer.Stop();
ThemeManager.ThemeChanged -= UpdateThemeControls;
if (_logger != null && _logHandler != null) _logger.OnLogMessage -= _logHandler;
_nav.Detach();
};
}
// ===== Seitenwechsel =====
/// <summary>
/// Hängt eine Seite in den Inhaltsbereich und setzt den Titel in der Kopfzeile.
/// Wird ausschließlich von <see cref="AvaloniaUiHost.OpenView(ModuleView)"/> gerufen –
/// dort liegt auch die Zwischenspeicherung der Seiten.
/// </summary>
public void ShowPage(string title, Control page)
{
_nav.PageTitle = title;
this.FindControl<ContentControl>("pageHost")!.Content = page;
}
// ===== Farbschema =====
private void UpdateThemeControls()
{
bool dark = ThemeManager.IsDark;
// Sonne = ins Helle wechseln, Mond = ins Dunkle (Symbolik der bisherigen Menüleiste).
var btn = this.FindControl<Button>("btnTheme")!;
btn.Content = dark ? "☀" : "🌙";
SetToolTip(btn, dark ? "Zum hellen Farbschema wechseln" : "Zum dunklen Farbschema wechseln");
this.FindControl<TextBlock>("lblTheme")!.Text = dark ? "Dunkelmodus" : "Hellmodus";
}
private static void SetToolTip(Control control, string text) =>
global::Avalonia.Controls.ToolTip.SetTip(control, text);
// ===== Statusleiste =====
private void UpdateStatus()
{
string trading = _state.GlobalTradingPaused
? "Pausiert"
: $"Live={_state.LiveTradingMode} / Demo={_state.DemoTradingMode}";
this.FindControl<TextBlock>("lblTrading")!.Text = $"Trading: {trading}";
int moduleCount = _services.GetServices<IPolyTraderModule>().Count();
this.FindControl<TextBlock>("lblModules")!.Text = $"Module: {moduleCount}";
this.FindControl<TextBlock>("lblRatelimit")!.Text =
$"API: {(_state.IsAlchemyHealthy ? "WSS aktiv" : "Polling")}";
}
// ===== Benachrichtigungsleiste =====
/// <summary>
/// Zeigt Warnungen und Fehler live an, sobald sie im Log auflaufen. Bewusst nur diese beiden
/// Stufen: alles andere gehört in die Terminal-Seite und nicht über jede Seite.
///
/// <para>Die wichtigsten Meldungen fallen beim <b>Start</b> an – fehlende Lizenz (dann laufen
/// keine Module), fehlender Master-Key, keine Datenbank. Die entstehen alle, bevor dieses
/// Fenster existiert, ein reines Abo würde sie also verpassen. Deshalb wird die Leiste
/// zuerst aus dem jüngsten Verlauf gefüllt.</para>
/// </summary>
private void SubscribeNotifications()
{
_logger = _services.GetService<TerminalLogger>();
if (_logger == null) return;
foreach (var e in _logger.GetHistory(TimeSpan.FromMinutes(10))
.Where(IsNoteworthy)
.TakeLast(MaxNotifications))
{
Add(e);
}
_logHandler = (_, e) =>
{
if (!IsNoteworthy(e)) return;
Dispatcher.UIThread.Post(() => Add(e));
};
_logger.OnLogMessage += _logHandler;
}
private static bool IsNoteworthy(LogMessageEventArgs e) =>
e.Level == LogLevel.Warning || e.Level == LogLevel.Error;
private void Add(LogMessageEventArgs e)
{
var note = new ShellNotification
{
Source = e.Timestamp.ToString("HH:mm:ss"),
Message = OneLine(e.Message),
IsError = e.Level == LogLevel.Error
};
note.Dismiss = new RelayCommand(() => _notifications.Remove(note));
_notifications.Insert(0, note);
while (_notifications.Count > MaxNotifications)
_notifications.RemoveAt(_notifications.Count - 1);
}
private static string OneLine(string s) =>
(s ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
}
}
@@ -1,10 +1,7 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.TerminalWindow"
Title="Terminal / Logs"
Width="1429" Height="1083">
<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.Views.TerminalWindow">
<!--
Terminal- und Log-Ansicht.
@@ -17,8 +14,6 @@
Zeilen verdrängt, der sichtbare Verlauf bleibt erhalten.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl>
<TabItem Header="Live">
@@ -94,4 +89,4 @@
</TabControl>
</DockPanel>
</Window>
</UserControl>
@@ -20,7 +20,7 @@ namespace PolyTrader.App.Avalonia.Views
/// Terminal-/Log-Ansicht. Live-Ausgabe des <see cref="TerminalLogger"/> plus Viewer für die
/// JSONL-Tagesdateien. Layout vollständig in TerminalWindow.axaml.
/// </summary>
public partial class TerminalWindow : Window
public partial class TerminalWindow : UserControl
{
/// <summary>
/// Obergrenze der angezeigten Zeilen. Die WinForms-Fassung leerte bei Erreichen einer
@@ -51,8 +51,6 @@ namespace PolyTrader.App.Avalonia.Views
public TerminalWindow(IModuleUiHost host, TerminalLogger logger) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.terminal", this);
_logger = logger;
this.FindControl<ItemsControl>("logLines")!.ItemsSource = _lines;
this.FindControl<DataGrid>("gridLogs")!.ItemsSource = _viewerRows;
@@ -94,11 +92,10 @@ namespace PolyTrader.App.Avalonia.Views
_timer.Tick += (_, _) => ProcessQueue();
_timer.Start();
Closed += (_, _) =>
{
_timer.Stop();
if (_logger != null && _logHandler != null) _logger.OnLogMessage -= _logHandler;
};
// Kein Abmelden mehr: Die Seite wird in der Einfenster-Shell einmal erzeugt und läuft
// bis zum Programmende weiter – genau wie früher ein offen gelassenes Fenster. Der
// Takt bleibt deshalb absichtlich an, damit der Verlauf beim Zurückwechseln vollständig
// ist und keine Meldungen fehlen.
ProcessQueue();
}
@@ -166,7 +163,9 @@ namespace PolyTrader.App.Avalonia.Views
var sb = new StringBuilder();
foreach (var l in _lines) sb.AppendLine(l.Text);
var clipboard = GetTopLevel(this)?.Clipboard;
// Seit der Umstellung auf Seiten ist „this" kein TopLevel mehr – die Zwischenablage
// hängt am Shell-Fenster und wird über den Baum aufgelöst.
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard != null) await clipboard.SetTextAsync(sb.ToString());
}
catch