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
+338 -84
View File
@@ -3,71 +3,112 @@
x:Class="PolyTrader.App.Avalonia.App">
<!--
Farbschema. RequestedThemeVariant wird NICHT hier festgelegt, sondern beim Start vom
ThemeManager aus den Server-Settings gesetzt (hell / dunkel / dem System folgen).
Farbschema und Designsprache. RequestedThemeVariant wird NICHT hier festgelegt, sondern beim
Start vom ThemeManager aus den Server-Settings gesetzt (hell / dunkel / dem System folgen).
Alle projekteigenen Farben stehen als Token in beiden Varianten. Im XAML werden sie über
DynamicResource gebunden dadurch wechseln sie beim Umschalten von selbst. Nur wenige
Stellen setzen Farben im Code (Grid-Zeilen, Umschalter); die holen sie über
ThemeManager.Brush(...) und zeichnen sich bei ThemeChanged neu.
Die Werte stammen aus dem Claude-Design-Mockup „PolyTrader UI Redesign" (Design-System
„Industry"): gedämpftes Blaugrau als Akzent, papierne Grundfläche, Haarlinien statt Kästen,
eckige Ecken. Farbmischungen des Mockups (color-mix) sind hier ausgerechnet, weil XAML
keine Mischfunktion kennt.
Bewusste Abweichung: Das Mockup färbt Gewinne im Akzentblau. PolyTrader bleibt bei Grün/Rot
für Gewinn/Verlust die Bedeutungsfarbe ist im Handelskontext eingeführt und trägt weiter
als eine reine Stilentscheidung. Die Töne sind aber an die neue Palette angeglichen
(entsättigt statt Signalfarbe).
-->
<Application.Resources>
<ResourceDictionary>
<!-- Schriften des Mockups. Avalonia probiert die Liste der Reihe nach durch: sind
Barlow / Barlow Condensed lokal installiert, werden sie genommen, sonst fällt es
auf die mitgelieferte Inter zurück. Damit läuft die App auch dort sauber, wo die
Schriften fehlen (Linux-Zielsystem, Build-Server). -->
<FontFamily x:Key="AppFontBody">Barlow, Inter, Segoe UI, sans-serif</FontFamily>
<FontFamily x:Key="AppFontHeading">Barlow Condensed, Barlow, Inter, Segoe UI, sans-serif</FontFamily>
<FontFamily x:Key="AppFontMono">Consolas, Menlo, DejaVu Sans Mono, monospace</FontFamily>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<!-- Flächen und Rahmen -->
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#FAFAFA" />
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="AppCardBrush" Color="#F5F5F5" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#DDDDDD" />
<!-- Grundflächen und Rahmen -->
<SolidColorBrush x:Key="AppBgBrush" Color="#F2F2F3" />
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#E9E9EA" />
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#EDEDEE" />
<SolidColorBrush x:Key="AppCardBrush" Color="#E9E9EA" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#D0D0D1" />
<SolidColorBrush x:Key="AppDividerBrush" Color="#D0D0D1" />
<!-- Texte -->
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#777777" />
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#666666" />
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#444444" />
<SolidColorBrush x:Key="AppTextBrush" Color="#1D1F20" />
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#76787A" />
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#66686A" />
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#444647" />
<!-- Akzent -->
<SolidColorBrush x:Key="AppAccentBrush" Color="#5980A6" />
<SolidColorBrush x:Key="AppAccentAltBrush" Color="#728FAB" />
<SolidColorBrush x:Key="AppAccentStrongBrush" Color="#416180" />
<!-- Akzent 16 % auf der Grundfläche: Auswahl- und Aktiv-Hinterlegung -->
<SolidColorBrush x:Key="AppAccentSoftBrush" Color="#DAE0E7" />
<!-- Bedeutungsfarben -->
<SolidColorBrush x:Key="AppPositiveBrush" Color="#2E7D32" />
<SolidColorBrush x:Key="AppNegativeBrush" Color="#C62828" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#996600" />
<!-- Zeilenfärbung der Trade-Listen (Pastell auf hellem Grund) -->
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#F5C8C8" />
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#D4F0D4" />
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#8CD68C" />
<SolidColorBrush x:Key="AppPositiveBrush" Color="#2F6B46" />
<SolidColorBrush x:Key="AppNegativeBrush" Color="#8E3229" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#8A6420" />
<!-- Benachrichtigungsleiste (Kante links + Symbol) -->
<SolidColorBrush x:Key="AppNotifyWarnBrush" Color="#B5822A" />
<SolidColorBrush x:Key="AppNotifyErrorBrush" Color="#C0392B" />
<!-- Zeilenfärbung der Trade-Listen (gedämpft auf hellem Grund) -->
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#EFD4D0" />
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#D8E7DC" />
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#A8CDB4" />
<!-- Trading-Umschalter im Launcher -->
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#90EE90" />
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#FFA500" />
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#CD5C5C" />
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#A8CFAF" />
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#E0B26A" />
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#D89A92" />
<!-- Supervisor-Analyseverlauf -->
<SolidColorBrush x:Key="AppChatUserBrush" Color="#4682B4" />
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#2E8B57" />
<SolidColorBrush x:Key="AppChatUserBrush" Color="#5980A6" />
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#4E8168" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<!-- Flächen und Rahmen -->
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#333333" />
<SolidColorBrush x:Key="AppCardBrush" Color="#2F2F2F" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#484848" />
<!-- Grundflächen und Rahmen -->
<SolidColorBrush x:Key="AppBgBrush" Color="#1A1C1E" />
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#232527" />
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#1F2123" />
<SolidColorBrush x:Key="AppCardBrush" Color="#232527" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#3C3E40" />
<SolidColorBrush x:Key="AppDividerBrush" Color="#3C3E40" />
<!-- Texte: heller, aber nicht reinweiß sonst flimmert es auf Dunkel -->
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#9A9A9A" />
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#AAAAAA" />
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#CFCFCF" />
<SolidColorBrush x:Key="AppTextBrush" Color="#EEF0F1" />
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#8D9092" />
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#A2A5A7" />
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#C9CCCE" />
<!-- Akzent: auf Dunkel aufgehellt, damit der Kontrast trägt -->
<SolidColorBrush x:Key="AppAccentBrush" Color="#7EA3C6" />
<SolidColorBrush x:Key="AppAccentAltBrush" Color="#8FA8C2" />
<SolidColorBrush x:Key="AppAccentStrongBrush" Color="#B5D9FD" />
<SolidColorBrush x:Key="AppAccentSoftBrush" Color="#2A3239" />
<!-- Bedeutungsfarben: gleiche Aussage, aufgehellt für Kontrast auf Dunkel -->
<SolidColorBrush x:Key="AppPositiveBrush" Color="#81C784" />
<SolidColorBrush x:Key="AppNegativeBrush" Color="#EF5350" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#FFB74D" />
<SolidColorBrush x:Key="AppPositiveBrush" Color="#6FAE86" />
<SolidColorBrush x:Key="AppNegativeBrush" Color="#D9756A" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#D3A45E" />
<SolidColorBrush x:Key="AppNotifyWarnBrush" Color="#D3A45E" />
<SolidColorBrush x:Key="AppNotifyErrorBrush" Color="#D9756A" />
<!-- Zeilenfärbung: gedämpft statt pastell die hellen Töne würden auf Dunkel
blenden und den Text unlesbar machen. Reihenfolge der Aussage bleibt. -->
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#4E2A2A" />
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#2C3F2C" />
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#3D6B3D" />
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#4A2B27" />
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#2A3A30" />
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#3A5C46" />
<!-- Trading-Umschalter -->
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#3F7A3F" />
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#A76B1F" />
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#3F7A55" />
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#9A6C25" />
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#8E3B3B" />
<!-- Supervisor-Analyseverlauf -->
<SolidColorBrush x:Key="AppChatUserBrush" Color="#7FB3D8" />
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#68B68A" />
<SolidColorBrush x:Key="AppChatUserBrush" Color="#7EA3C6" />
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#78B49A" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
@@ -77,62 +118,275 @@
<Application.Styles>
<FluentTheme />
<!-- Projektweite Grundgestaltung. Bewusst hier zentral statt je Fenster wiederholt,
damit alle Fenster gleich aussehen (wie zuvor die einheitliche Segoe-UI-Vorgabe). -->
<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" />
<!-- ═══ Grundgestaltung ═══════════════════════════════════════════════════════
Projektweit zentral statt je Fenster wiederholt, damit alle Ansichten gleich
aussehen. Die Designsprache des Mockups lebt fast vollständig hier. -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Background" Value="{DynamicResource AppBgBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}" />
</Style>
<!-- KPI-Kachel: entspricht den zweizeiligen Labels der WinForms-Oberfläche
("<Titel> \n <Wert>"), siehe docs/UI-SPEZIFIKATION-WinForms.md. -->
<Style Selector="Border.kpi">
<Setter Property="Background" Value="{DynamicResource AppCardBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
<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" />
<!-- Überschriften laufen in der schmalen Schnittvariante (Barlow Condensed). -->
<Style Selector="TextBlock.h4">
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="FontSize" Value="19" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Border.kpi > StackPanel > TextBlock.caption">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{DynamicResource AppCaptionTextBrush}" />
</Style>
<Style Selector="Border.kpi > StackPanel > TextBlock.value">
<Setter Property="FontSize" Value="18" />
<Style Selector="TextBlock.h5">
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Werkzeugleiste oben in jedem Fenster (Ersatz für ToolStrip). -->
<Style Selector="Border.toolbar">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
<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="{DynamicResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="8,4" />
<!-- „Kicker": die kleine Versalzeile in Akzentfarbe über einer Überschrift.
Avalonia kennt kein text-transform die Texte stehen im XAML in Versalien. -->
<Style Selector="TextBlock.kicker">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
</Style>
<!-- Gedämpfte Hinweistexte (Statuszeilen, Erklärungen unter Feldern). -->
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
</Style>
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
<Setter Property="FontFamily" Value="{DynamicResource AppFontMono}" />
</Style>
<!-- ═══ Steuerelemente: eckig und hairline ════════════════════════════════════
Das Mockup setzt border-radius durchgängig auf 0 und zeichnet Kästen nur mit
einer 1px-Linie. Fluent bringt gerundete, gefüllte Flächen mit deshalb hier
flächendeckend zurückgesetzt. -->
<Style Selector="Button">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Padding" Value="10,5" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- Fluent malt Füllung und Rahmen im Vorlagenteil und überschreibt sie je Zustand.
Deshalb müssen Schwebe- und Druckzustand dort gesetzt werden, sonst blitzt beim
Überfahren wieder das Fluent-Grau durch. -->
<Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
</Style>
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
</Style>
<!-- Hauptaktion: gefüllt im Akzent. -->
<Style Selector="Button.primary">
<Setter Property="Foreground" Value="{DynamicResource AppBgBrush}" />
</Style>
<Style Selector="Button.primary /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
</Style>
<Style Selector="Button.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppAccentStrongBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentStrongBrush}" />
</Style>
<!-- Rahmenlose Schaltfläche (Seitenleiste, Symbolknöpfe). -->
<Style Selector="Button.ghost">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="TextBox, ComboBox, NumericUpDown, CalendarDatePicker, AutoCompleteBox">
<Setter Property="CornerRadius" Value="0" />
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
<Setter Property="MinHeight" Value="30" />
</Style>
<Style Selector="CheckBox, RadioButton">
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
</Style>
<Style Selector="TabItem">
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="FontSize" Value="15" />
<Setter Property="MinHeight" Value="38" />
</Style>
<!-- ═══ Tabellen ══════════════════════════════════════════════════════════════
Kopfzeile klein, gesperrt und gedämpft; Zeilen nur durch eine Haarlinie
getrennt (keine Gitterkästen). -->
<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="26" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource AppDividerBrush}" />
</Style>
<Style Selector="DataGridColumnHeader">
<!-- Kein LetterSpacing: DataGridColumnHeader ist ein ContentControl und kennt die
Eigenschaft nicht (nur TextBlock hat sie). Die Sperrung der Mockup-Kopfzeile
entfällt hier deshalb bewusst. -->
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="7,5" />
</Style>
<!-- ═══ Bausteine ═════════════════════════════════════════════════════════════ -->
<!-- KPI-Kachel: entspricht den zweizeiligen Labels der WinForms-Oberfläche
("<Titel> \n <Wert>"), siehe docs/UI-SPEZIFIKATION-WinForms.md. -->
<Style Selector="Border.kpi">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="Margin" Value="0,0,10,10" />
<Setter Property="MinWidth" Value="160" />
</Style>
<Style Selector="Border.kpi > StackPanel > TextBlock.caption">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
</Style>
<Style Selector="Border.kpi > StackPanel > TextBlock.value">
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Allgemeiner Rahmen-Kasten (Detailbereiche, Log-Fläche). -->
<Style Selector="Border.card">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="12" />
</Style>
<!-- Statuskennzeichen in Listen und Kopfzeilen. -->
<Style Selector="Border.tag">
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="8,2" />
</Style>
<!-- Werkzeugleiste oben in jeder Ansicht (Ersatz für ToolStrip). Im neuen Design
ohne Füllfläche nur die Schaltflächen stehen auf dem Grund. -->
<Style Selector="Border.toolbar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="0,0,0,10" />
</Style>
<Style Selector="Border.toolbar Button">
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="Padding" Value="10,5" />
<Setter Property="FontSize" Value="12.5" />
</Style>
<!-- Statusleiste unten (Ersatz für StatusStrip). -->
<Style Selector="Border.statusbar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
<Setter Property="BorderThickness" Value="0,1,0,0" />
<Setter Property="Padding" Value="20,5" />
</Style>
<Style Selector="Border.statusbar TextBlock">
<Setter Property="FontSize" Value="11.5" />
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Senkrechter Trenner in Werkzeugleisten. -->
<Style Selector="Border.sep">
<Setter Property="Width" Value="1" />
<Setter Property="Height" Value="18" />
<Setter Property="Margin" Value="4,0,10,0" />
<Setter Property="Background" Value="{DynamicResource AppDividerBrush}" />
</Style>
<!-- Gruppenkasten (Launcher-Widgets). Überschrift wie ein Kicker gesetzt. -->
<Style Selector="HeaderedContentControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="GridSplitter">
<Setter Property="Background" Value="{DynamicResource AppDividerBrush}" />
</Style>
<!-- ═══ Seitenleiste der Shell ════════════════════════════════════════════════ -->
<Style Selector="Button.nav">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Padding" Value="9,7" />
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
<Setter Property="FontSize" Value="13" />
</Style>
<!-- Eingeklappt steht nur das Symbol da dann mittig statt links. -->
<Style Selector="Button.nav.icononly">
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.nav.active">
<Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<!-- Abschnittsüberschrift in der Seitenleiste. -->
<Style Selector="TextBlock.navgroup">
<Setter Property="FontSize" Value="10" />
<Setter Property="LetterSpacing" Value="1.4" />
<Setter Property="Margin" Value="9,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
</Style>
<!-- ═══ Benachrichtigungsleiste ═══════════════════════════════════════════════
Farbige Kante links, sonst nur eine gedämpfte Bandfläche wie im Mockup. -->
<Style Selector="Border.notify">
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AppNotifyWarnBrush}" />
<Setter Property="BorderThickness" Value="3,0,0,0" />
<Setter Property="Padding" Value="17,6" />
</Style>
<Style Selector="Border.notify.error">
<Setter Property="BorderBrush" Value="{DynamicResource AppNotifyErrorBrush}" />
</Style>
</Application.Styles>
</Application>
+8 -4
View File
@@ -42,12 +42,16 @@ namespace PolyTrader.App.Avalonia
ModuleViews.Register(uiHost, Services);
ViewIcons.AssignDefaults(uiHost);
var launcher = new LauncherWindow(uiHost, Services);
uiHost.SetMainWindow(launcher);
desktop.MainWindow = launcher;
// Einfenster-Shell: ein Fenster, die Views hängen als Seiten darin.
var shell = new ShellWindow(uiHost, Services);
uiHost.SetShell(shell);
desktop.MainWindow = shell;
// Schliessen des Launchers laeuft ueber die Sicherheitsabfrage (wie bisher das X).
// Schliessen der Shell laeuft ueber die Sicherheitsabfrage (wie bisher das X).
desktop.ShutdownMode = global::Avalonia.Controls.ShutdownMode.OnMainWindowClose;
// Erste Seite: die Startseite (frühere LauncherForm).
uiHost.ActivateMain();
}
base.OnFrameworkInitializationCompleted();
@@ -1,51 +0,0 @@
<UserControl 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.Controls.WindowMenuBar"
x:DataType="shell:WindowMenuModel">
<!--
Gemeinsame Fenster-Menüleiste, die auf JEDEM PolyTrader-Fenster oben sitzt.
Alle Fenster stehen nebeneinander (nicht in einem Untermenü versteckt), das aktuelle
fett, offene mit Haken. Die rechte Aktion ist kontextabhängig.
Vollständig deklarativ: die Einträge kommen als Datenmodell aus WindowMenuModel.Entries,
hier wird nur beschrieben, wie ein Eintrag aussieht. Kein Aufbau zur Laufzeit im Code.
(Avalonia kennt kein ToolStripItemAlignment.Right — die rechte Ausrichtung entsteht
dadurch, dass der letzte Eintrag im DockPanel rechts angedockt wird.)
-->
<Border Background="{DynamicResource AppSurfaceAltBrush}" BorderBrush="{DynamicResource AppBorderBrush}" BorderThickness="0,0,0,1" Padding="4,2">
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shell:WindowMenuEntry">
<Button Command="{Binding Command}"
ToolTip.Tip="{Binding ToolTip}"
Background="Transparent"
BorderThickness="0"
Padding="8,4"
Margin="0,0,2,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Image Source="{Binding Icon}"
Width="20" Height="20"
VerticalAlignment="Center"
IsVisible="{Binding Icon, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Text="{Binding CheckMark}"
VerticalAlignment="Center"
Foreground="{DynamicResource AppPositiveBrush}" />
<TextBlock Text="{Binding Title}"
VerticalAlignment="Center"
FontWeight="{Binding FontWeight}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</UserControl>
@@ -1,30 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.Core.Modularity;
namespace PolyTrader.App.Avalonia.Controls
{
/// <summary>
/// Code-Behind der Fenster-Menüleiste: nur Verdrahtung, kein Layout (siehe Layout-Regel).
/// Jedes Fenster ruft <see cref="Attach"/> auf und bekommt damit dieselbe Leiste.
/// </summary>
public partial class WindowMenuBar : UserControl
{
private WindowMenuModel? _model;
public WindowMenuBar() => AvaloniaXamlLoader.Load(this);
/// <summary>
/// Verbindet die Leiste mit der Shell. <paramref name="currentViewId"/> ist <c>null</c> für den
/// Launcher (rechte Aktion „Beenden"), sonst die View-ID (rechte Aktion „Fenster schließen").
/// Die Event-Registrierung wird beim Entladen wieder gelöst kein Leak.
/// </summary>
public void Attach(IModuleUiHost host, string? currentViewId, Window owner)
{
_model = new WindowMenuModel(host, currentViewId, owner.Close);
DataContext = _model;
owner.Closed += (_, _) => _model?.Detach();
}
}
}
@@ -0,0 +1,143 @@
using System;
using System.Linq;
using Deploymentcenter.Client;
using PolyTrader.Core;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
namespace PolyTrader.App.Avalonia.Licensing
{
/// <summary>
/// Kopflose Lizenzverwaltung von der Kommandozeile aus (D-2), nach der Konvention aus
/// LICENSE_INTEGRATION_GUIDE §4: <c>--license-status</c>, <c>--license-set-key</c>,
/// <c>--license-deactivate</c>. Diese Schalter baut die Anwendung selbst, das SDK bringt nur die
/// Bausteine mit. Läuft VOR dem Host-Aufbau und beendet den Prozess über den Rückgabewert.
/// </summary>
internal static class LicenseCli
{
/// <summary>Liefert <c>true</c>, wenn einer der Lizenz-Schalter erkannt und abgearbeitet wurde.</summary>
public static bool TryHandle(string[] args, out int exitCode)
{
exitCode = 0;
if (args.Any(a => string.Equals(a, "--license-status", StringComparison.OrdinalIgnoreCase)))
{
exitCode = Status();
return true;
}
int setIdx = Array.FindIndex(args, a => string.Equals(a, "--license-set-key", StringComparison.OrdinalIgnoreCase));
if (setIdx >= 0)
{
string? key = setIdx + 1 < args.Length ? args[setIdx + 1] : null;
exitCode = SetKey(key);
return true;
}
if (args.Any(a => string.Equals(a, "--license-deactivate", StringComparison.OrdinalIgnoreCase)))
{
exitCode = Deactivate();
return true;
}
return false;
}
private static int Status()
{
var options = Program.LoadDeploymentcenterOptions();
var hw = HardwareId.GetHardwareId(options.ProjectSlug);
var cached = LicenseClient.TryGetCachedState(options.ProjectSlug);
Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})");
Console.WriteLine($"Produkt: {options.ProjectSlug}");
Console.WriteLine($"Server: {(string.IsNullOrWhiteSpace(options.BaseUrl) ? "(keine URL konfiguriert)" : options.BaseUrl)}");
Console.WriteLine($"Cache: {(cached == null ? "leer" : cached.Status)}");
if (cached != null && cached.CacheExpiresAt > 0)
{
var until = DateTimeOffset.FromUnixTimeSeconds(cached.CacheExpiresAt);
Console.WriteLine($"Offline noch bis: {until:u}");
}
var settings = ServerSettings.Load("server_settings.xml");
Console.WriteLine($"Gespeicherter Schlüssel (server_settings.xml): {(string.IsNullOrWhiteSpace(settings.LicenseKey) ? "keiner" : settings.LicenseKeyStatus)}");
return 0;
}
private static int SetKey(string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine("Fehler: --license-set-key erwartet den Schlüssel als nächstes Argument.");
return 1;
}
var options = Program.LoadDeploymentcenterOptions();
if (string.IsNullOrWhiteSpace(options.BaseUrl))
{
Console.Error.WriteLine("Fehler: Keine Deploymentcenter-URL konfiguriert (appsettings.json: Deploymentcenter:BaseUrl).");
return 1;
}
LicenseClient.DefaultAppVersion = BuildInfo.Version;
var client = new LicenseClient();
LicenseValidationResult result = client
.ValidateAsync(options.ProjectSlug, key.Trim(), options.BaseUrl, BuildInfo.Version)
.GetAwaiter().GetResult();
Console.WriteLine($"{result.Status}: {result.Message}");
if (!result.IsValid)
{
return 1;
}
const string settingsPath = "server_settings.xml";
var settings = ServerSettings.Load(settingsPath);
settings.LicenseKey = SecretProtection.Protect(key.Trim());
settings.Save(settingsPath);
Console.WriteLine($"Lizenz gültig und in {settingsPath} gespeichert " +
$"({(SecretProtection.IsEncrypted(settings.LicenseKey) ? "verschlüsselt" : "Klartext")}).");
return 0;
}
private static int Deactivate()
{
var options = Program.LoadDeploymentcenterOptions();
var settings = ServerSettings.Load("server_settings.xml");
string key;
try { key = SecretProtection.Unprotect(settings.LicenseKey); }
catch (Exception ex)
{
Console.Error.WriteLine($"Fehler: gespeicherter Schlüssel nicht lesbar: {ex.Message}");
return 1;
}
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine("Fehler: kein Lizenzschlüssel hinterlegt.");
return 1;
}
// Die Deaktivierung gibt einen Aktivierungsplatz frei und ist deshalb geschützt - der
// shared_key gehört auf den Administrationsrechner, nicht in die ausgelieferte
// Anwendung (siehe LICENSE_INTEGRATION_GUIDE). Ohne ihn: klarer Hinweis statt Versuch.
string? sharedKey = Environment.GetEnvironmentVariable("DC_SHARED_KEY");
if (string.IsNullOrWhiteSpace(sharedKey))
{
Console.Error.WriteLine("Fehler: DC_SHARED_KEY nicht gesetzt. Die Deaktivierung braucht den " +
"administrativen Shared Key - alternativ im Deploymentcenter-WebUI unter " +
"der Hardware-Liste auf „Freigeben\" klicken.");
return 1;
}
var client = new LicenseClient();
bool ok = client.DeactivateAsync(options.ProjectSlug, key, options.BaseUrl, sharedKey)
.GetAwaiter().GetResult();
Console.WriteLine(ok ? "Aktivierung freigegeben." : "Deaktivierung fehlgeschlagen.");
return ok ? 0 : 1;
}
}
}
@@ -0,0 +1,105 @@
using System;
using Deploymentcenter.Client;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Licensing
{
/// <summary>
/// Lizenz-Torwächter für den Programmstart (D-2). Ersetzt den früheren, gegen
/// <c>LicenseLabrador.Client</c> gebauten <c>Licensing/LicenseGate.cs</c> im WinForms-Projekt
/// (siehe docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md) dort läuft
/// ohnehin keine Lizenzprüfung, weil Avalonia die tatsächlich benutzte Anwendung ist.
///
/// **Grundsatz unverändert:** kein <see cref="Environment.Exit(int)"/> bei ungültiger Lizenz. Ein
/// Trading-Bot mit offenen Positionen darf nicht mitten im Lauf abgeschossen werden. Bei
/// fehlender/ungültiger Lizenz startet die Core-Shell (Terminal, Einstellungen) ohne die
/// Trading-/Analyse-Module; der Schlüssel lässt sich jederzeit über den Button
/// „Lizenzschlüssel setzen …" im Einstellungsfenster nachtragen (wirkt ab dem nächsten Start
/// eine Laufzeit-Revalidierung gibt es bewusst nicht, siehe Entscheidung vom 31.07.2026).
///
/// **Bewusst kein eigenes <see cref="ILicensePrompt"/>:** Der interaktive Weg läuft über die
/// bereits vorhandene, erprobte Settings-UI (derselbe maskierte Prompt-Button wie beim
/// Watchdog-Token); ein zusätzlicher Dialog nur für die Lizenz wäre eine zweite, redundante
/// Eingabe-Oberfläche. Für den Kopflos-Betrieb (<c>--headless</c>, CLI-Schalter) genügt die vom
/// SDK mitgelieferte <see cref="ConsoleLicensePrompt"/> eine eigene Fassung böte keinen Mehrwert.
/// </summary>
public static class LicenseGate
{
/// <summary>
/// Prüft still (ohne Dialog), ob die gespeicherte Lizenz aktuell nutzbar ist. Entscheidet
/// darüber, ob die Trading-/Analyse-Module überhaupt registriert werden muss deshalb vor
/// dem Host-Aufbau laufen. Wirft nie: jeder Fehler in der Prüfung führt zum eingeschränkten
/// Modus, nicht zum Absturz.
/// </summary>
public static bool CheckUsable(DeploymentcenterOptions options, ServerSettings settings, TerminalLogger logger)
{
try
{
if (string.IsNullOrWhiteSpace(options.BaseUrl))
{
logger.Warning("⚠️ Keine Deploymentcenter-URL konfiguriert. Start im eingeschränkten Modus (nur Core-Shell).");
return false;
}
// Befund D-3 aus dem Umsetzungsplan: das SDK erzwingt selbst kein TLS. Ohne diese
// Prüfung ginge der Lizenzschlüssel im Klartext übers Netz, wenn die URL versehentlich
// auf http:// steht.
if (!Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var parsed) ||
!(parsed.Scheme == Uri.UriSchemeHttps || (parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)))
{
logger.Warning($"⚠️ Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{options.BaseUrl}'. " +
"Start im eingeschränkten Modus.");
return false;
}
string key;
try
{
// Erlaubt einen mit dem Master-Key verschlüsselten Schlüssel (enc:v1:…);
// Klartext wird unverändert durchgereicht.
key = SecretProtection.Unprotect(settings.LicenseKey);
}
catch (Exception ex)
{
logger.Warning($"⚠️ Gespeicherter Lizenzschlüssel nicht lesbar ({ex.Message}) behandle als 'keine Lizenz'.");
return false;
}
if (string.IsNullOrWhiteSpace(key))
{
logger.Warning("⚠️ Kein Lizenzschlüssel gesetzt. Start im eingeschränkten Modus (nur Core-Shell). " +
"Schlüssel über „Lizenzschlüssel setzen …\" im Einstellungsfenster eintragen.");
return false;
}
LicenseClient.DefaultAppVersion = BuildInfo.Version;
var client = new LicenseClient();
LicenseValidationResult result = client
.ValidateAsync(options.ProjectSlug, key, options.BaseUrl, BuildInfo.Version)
.GetAwaiter().GetResult();
if (result.IsValid)
{
logger.Info(result.IsCached
? $"🔑 Lizenz im Offline-Kulanzmodus gültig ({result.Status})."
: "🔑 Lizenz gültig (online geprüft).");
return true;
}
logger.Warning($"⚠️ Keine gültige Lizenz ({result.Status}: {result.Message}). " +
"Start im eingeschränkten Modus (nur Core-Shell, keine Module). " +
"Schlüssel über „Lizenzschlüssel setzen …\" im Einstellungsfenster prüfen.");
return false;
}
catch (Exception ex)
{
logger.Error($"❌ Lizenzprüfung unerwartet fehlgeschlagen: {ex.Message}. Start im eingeschränkten Modus.");
return false;
}
}
}
}
@@ -50,6 +50,9 @@
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
<!-- D-5: vom Deploymentcenter-Installer gelesen (update-agent im Modus "install"). Gehört ins
Publish-Verzeichnis, landet damit im Release-Paket neben der manifest.json. -->
<None Update="setup.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+95 -17
View File
@@ -41,7 +41,18 @@ namespace PolyTrader.App.Avalonia
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", StringComparison.OrdinalIgnoreCase));
var host = BuildHost(out var bootLog);
// D-2: Lizenz-CLI-Schalter (--license-status/--license-set-key/--license-deactivate)
// laufen VOR dem Host-Aufbau und beenden sich selbst - kein Sinn, dafür Trading-Dienste
// hochzufahren.
if (Licensing.LicenseCli.TryHandle(args, out int cliExitCode))
return cliExitCode;
var dcOptions = LoadDeploymentcenterOptions();
// Konstruktionsprüfung (--smoke-ui) testet Fensteraufbau, keine Lizenzlogik - dort
// bewusst ALLE Module registrieren, sonst würde die Prüfung auf einem Build-Server
// (nie lizenziert) still einen Großteil ihrer Abdeckung verlieren.
var host = BuildHost(dcOptions, enforceLicense: !smoke, out var bootLog);
// Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
@@ -82,11 +93,35 @@ namespace PolyTrader.App.Avalonia
.WithInterFont()
.LogToTrace();
/// <summary>
/// Lädt NUR den Deploymentcenter-Konfigurationsblock, bevor der Generic Host existiert
/// die Lizenzprüfung (D-2) muss VOR der Modul-Liste laufen, also bevor
/// <see cref="Host.CreateDefaultBuilder"/> seine eigene <see cref="IConfiguration"/> baut.
/// Liest dieselben Dateien wie der Host später (appsettings.json + appsettings.Local.json),
/// nur eigenständig und minimal. Von <see cref="Licensing.LicenseCli"/> mitbenutzt.
/// </summary>
internal static DeploymentcenterOptions LoadDeploymentcenterOptions()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
var options = new DeploymentcenterOptions();
config.GetSection(DeploymentcenterOptions.SectionName).Bind(options);
return options;
}
/// <summary>
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
/// aktiven Module. Kennt die Oberfläche nicht.
/// </summary>
private static IHost BuildHost(out TerminalLogger bootLog)
/// <param name="enforceLicense">
/// D-2: Wenn <c>false</c> (nur <c>--smoke-ui</c>), werden immer alle Module registriert
/// die Konstruktionsprüfung testet Fensteraufbau, keine Lizenzlogik.
/// </param>
private static IHost BuildHost(DeploymentcenterOptions dcOptions, bool enforceLicense, out TerminalLogger bootLog)
{
var serverSettings = ServerSettings.Load(ServerSettingsPath);
var logger = new TerminalLogger();
@@ -115,6 +150,16 @@ namespace PolyTrader.App.Avalonia
new AccountingModule()
};
var disabled = new HashSet<string>(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
// D-2: ohne gültige Lizenz startet nur die Core-Shell (Terminal, Einstellungen) - alle
// Trading-/Analyse-Module bleiben unregistriert. Kein Environment.Exit (Entscheidung
// 31.07.2026): der Schlüssel lässt sich im Einstellungsfenster nachtragen.
bool licensed = !enforceLicense || Licensing.LicenseGate.CheckUsable(dcOptions, serverSettings, logger);
if (!licensed)
{
foreach (var m in allModules) disabled.Add(m.Name);
}
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
return Host.CreateDefaultBuilder()
@@ -123,6 +168,7 @@ namespace PolyTrader.App.Avalonia
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
services.AddSingleton(dcOptions);
var databaseOptions = new DatabaseOptions
{
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
@@ -147,7 +193,22 @@ namespace PolyTrader.App.Avalonia
services.AddSingleton<INotificationSink>(
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>()));
services.AddSingleton(sp =>
{
var watchdog = new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>());
// D-1: erster "checks"-Eintrag - beweist die Mechanik mit einem real
// verfügbaren Signal. db/market_feed folgen, sobald die jeweiligen Dienste
// ein Gesundheitssignal nach außen geben (siehe
// docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, D-1).
var vpn = sp.GetRequiredService<MullvadVpnService>();
var serverSettings = sp.GetRequiredService<ServerSettings>();
watchdog.RegisterCheck("vpn", () => serverSettings.VpnEnabled
? (vpn.IsConnected, vpn.IsConnected ? null : "Mullvad VPN nicht verbunden")
: (true, null)); // VPN bewusst aus -> kein Fehlalarm
return watchdog;
});
// MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
@@ -159,9 +220,15 @@ namespace PolyTrader.App.Avalonia
module.RegisterServices(services, context.Configuration);
}
// D-3: Fehler-Reporting - rein ereignisgesteuert (AppDomain.UnhandledException,
// TaskScheduler.UnobservedTaskException, TerminalLogger-Fehler), kein eigener Takt.
services.AddSingleton(sp => new DeploymentcenterErrorReporter(
sp.GetRequiredService<TerminalLogger>(), sp.GetRequiredService<DeploymentcenterOptions>()));
services.AddHostedService<MarketSyncService>();
services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>());
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton<AvaloniaUiHost>();
@@ -189,7 +256,7 @@ namespace PolyTrader.App.Avalonia
ViewIcons.AssignDefaults(uiHost);
int failures = 0;
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ===");
Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ===");
foreach (var view in uiHost.Views)
{
try
@@ -204,15 +271,17 @@ namespace PolyTrader.App.Avalonia
}
}
// Die Startseite steckt seit der Einfenster-Shell in der Schleife oben (core.start);
// separat geprueft wird nur noch das Shell-Fenster selbst.
try
{
_ = new Views.LauncherWindow(uiHost, host.Services);
Console.WriteLine("[OK] LauncherWindow konstruiert");
_ = new Views.ShellWindow(uiHost, host.Services);
Console.WriteLine("[OK] ShellWindow konstruiert");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] LauncherWindow: {ex.GetType().Name}: {ex.Message}");
Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}");
}
try
@@ -255,9 +324,12 @@ namespace PolyTrader.App.Avalonia
// Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau.
string[] themeKeys =
{
"AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush", "AppBorderBrush",
"AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
"AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush",
"AppBorderBrush", "AppDividerBrush",
"AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
"AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush",
"AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush",
"AppNotifyWarnBrush", "AppNotifyErrorBrush",
"AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush",
"AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush",
"AppChatUserBrush", "AppChatAgentBrush"
@@ -298,17 +370,23 @@ namespace PolyTrader.App.Avalonia
};
/// <summary>
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>) und aktiviert
/// die at-rest-Verschlüsselung. Muss VOR jeder Credential-Entschlüsselung laufen.
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>, sonst wird
/// einer erzeugt D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder
/// Credential-Entschlüsselung laufen.
/// </summary>
private static void ConfigureSecretProtection(TerminalLogger logger)
{
string? masterKey = Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY");
if (string.IsNullOrWhiteSpace(masterKey))
{
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
}
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
string? masterKey = MasterKeyResolver.Resolve(
keyFile,
() => Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"),
onGenerated: _ => logger.Warning(
"🔐 Kein Master-Key gefunden ein neuer wurde erzeugt und in \"master.key\" " +
"gespeichert (App-Ordner, gitignored). WICHTIG: Diese Datei SOFORT separat sichern " +
"(Passwort-Manager/Offline-Backup) ihr Verlust macht jedes ab jetzt verschlüsselte " +
"Secret unwiederbringlich unlesbar."),
onWarning: msg => logger.Warning($"⚠️ {msg}"));
SecretProtection.Configure(masterKey);
if (SecretProtection.IsConfigured)
@@ -7,19 +7,29 @@ using PolyTrader.Core.Modularity;
namespace PolyTrader.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, alle Fenster öffnen maximiert.
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>.
///
/// <para><b>Umstellung auf die Einfenster-Shell:</b> Früher öffnete jede View ein eigenes
/// Fenster. Jetzt gibt es genau ein Anwendungsfenster (<see cref="Views.ShellWindow"/>); eine
/// View ist eine <see cref="UserControl"/>-Seite, die in dessen Inhaltsbereich gehängt wird.
/// Eine Seite wird beim ersten Aufruf erzeugt und bleibt danach für die Sitzung bestehen
/// das entspricht dem alten „Fenster bleibt offen": Timer, Log-Abos und der Bildlaufstand
/// überleben den Seitenwechsel.</para>
///
/// <para>Der Core-Contract ist toolkit-neutral (<see cref="ModuleView.CreateView"/> liefert
/// <see cref="object"/>) hier wird auf <see cref="Window"/> gecastet. Ein falscher Typ ist ein
/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster erzeugen.</para>
/// <see cref="object"/>) hier wird auf <see cref="Control"/> gecastet. Ein falscher Typ ist ein
/// Programmierfehler und soll laut scheitern, nicht still eine leere Seite erzeugen.</para>
/// </summary>
public sealed class AvaloniaUiHost : IModuleUiHost
{
/// <summary>ID der Startseite (frühere LauncherForm/LauncherWindow).</summary>
public const string StartViewId = "core.start";
private readonly List<ModuleView> _views = new();
private readonly Dictionary<string, Window> _open = new();
private Window? _mainWindow;
private readonly List<UnavailableView> _unavailable = new();
private readonly Dictionary<string, Control> _pages = new();
private Views.ShellWindow? _shell;
private string? _activeViewId;
private bool _shutdownDialogOpen;
/// <summary>True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde.</summary>
@@ -29,20 +39,43 @@ namespace PolyTrader.App.Avalonia.Shell
public IReadOnlyList<ModuleView> Views => _views;
/// <summary>ID der gerade angezeigten Seite die Seitenleiste hebt sie hervor.</summary>
public string? ActiveViewId => _activeViewId;
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;
/// <summary>
/// Ein bekanntes, in dieser Sitzung aber nicht geladenes Modul. Solche Module haben keine
/// Seite die Seitenleiste zeigt sie trotzdem, ausgegraut und mit dem Grund als Hinweis.
/// </summary>
/// <param name="Reason">Warum das Modul fehlt (Lizenz, Abschaltung, fehlender API-Key …).</param>
public sealed record UnavailableView(string Title, string? IconKey, int Order, string Reason);
public bool IsOpen(string viewId) => _open.ContainsKey(viewId);
/// <summary>
/// Meldet ein nicht geladenes Modul an. Ohne das verschwände es spurlos aus der Oberfläche
/// in der Mehrfenster-Fassung fiel das kaum auf, in einer Seitenleiste ist eine unerklärt
/// leere Rubrik dagegen schlicht ein Fehler.
/// </summary>
public void RegisterUnavailable(UnavailableView view) => _unavailable.Add(view);
public void ActivateMain()
{
if (_mainWindow == null) return;
if (_mainWindow.WindowState == WindowState.Minimized)
_mainWindow.WindowState = WindowState.Maximized;
_mainWindow.Activate();
}
/// <summary>Bekannte, aber nicht geladene Module (siehe <see cref="RegisterUnavailable"/>).</summary>
public IReadOnlyList<UnavailableView> UnavailableViews => _unavailable;
/// <summary>Setzt das Shell-Fenster Ziel aller Seitenwechsel und Besitzer der Dialoge.</summary>
public void SetShell(Views.ShellWindow shell) => _shell = shell;
/// <summary>Das Shell-Fenster, oder <c>null</c> vor dessen Aufbau (z.B. im Smoke-Lauf).</summary>
public Window? ShellWindow => _shell;
/// <summary>
/// Wurde die Seite in dieser Sitzung schon einmal aufgerufen? Erbt die Bedeutung des früheren
/// „Fenster ist offen": die Seite existiert und arbeitet weiter, auch wenn gerade eine andere
/// angezeigt wird.
/// </summary>
public bool IsOpen(string viewId) => _pages.ContainsKey(viewId);
/// <summary>Zurück auf die Startseite.</summary>
public void ActivateMain() => OpenView(StartViewId);
public void OpenView(string viewId)
{
@@ -52,35 +85,20 @@ namespace PolyTrader.App.Avalonia.Shell
public void OpenView(ModuleView view)
{
if (_open.TryGetValue(view.Id, out var existing))
if (!_pages.TryGetValue(view.Id, out var page))
{
if (existing.WindowState == WindowState.Minimized)
existing.WindowState = WindowState.Normal;
existing.Activate();
return;
page = (Control)view.CreateView();
_pages[view.Id] = page;
}
var window = (Window)view.CreateView();
if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title;
// Vorgabe aus der WinForms-Oberflaeche uebernommen: alle Fenster maximiert (Full-HD-Ziel).
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
window.WindowState = WindowState.Maximized;
_open[view.Id] = window;
window.Closed += (_, _) =>
{
_open.Remove(view.Id);
OpenStateChanged?.Invoke();
};
window.Show();
_activeViewId = view.Id;
_shell?.ShowPage(view.Title, page);
OpenStateChanged?.Invoke();
}
/// <summary>
/// Zeigt die Sicherheitsabfrage (Beenden-Schaltfläche 10 s gesperrt) und fährt bei Bestätigung
/// herunter. Aus jedem Fenster aufrufbar auch aus Modul-Fenstern, die die App nicht kennen.
/// herunter. Aus jeder Seite aufrufbar auch aus Modul-Seiten, die die App nicht kennen.
/// </summary>
public async void RequestShutdown()
{
@@ -89,8 +107,8 @@ namespace PolyTrader.App.Avalonia.Shell
try
{
var dlg = new Views.ShutdownConfirmWindow();
bool confirmed = _mainWindow != null
? await dlg.ShowDialog<bool>(_mainWindow)
bool confirmed = _shell != null
? await dlg.ShowDialog<bool>(_shell)
: await dlg.ShowDialog<bool>(new Window());
if (!confirmed) return;
+19 -3
View File
@@ -10,7 +10,7 @@ using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Shell
{
/// <summary>
/// Registriert die Fenster, die der Core selbst beisteuert (Dashboard, Einstellungen,
/// Registriert die Seiten, die der Core selbst beisteuert (Start, Dashboard, Einstellungen,
/// Terminal, Jobs). Gegenstück zur entsprechenden Registrierung im WinForms-Program.cs
/// die View-IDs sind bewusst identisch, damit Symbole und Reihenfolge unverändert greifen.
/// </summary>
@@ -21,10 +21,25 @@ namespace PolyTrader.App.Avalonia.Shell
public static void Register(IModuleUiHost host, IServiceProvider services)
{
// Die Startseite ist seit der Einfenster-Shell eine View wie jede andere: sie steht in
// der Seitenleiste, wird über OpenView aufgerufen und beim Start als erste angezeigt.
// Sie heißt „Dashboard", weil sie genau das ist: Kennzahlen, Konten und Live-Überblick.
host.RegisterView(new ModuleView
{
Id = AvaloniaUiHost.StartViewId,
Title = "Dashboard",
Group = "Core",
Order = 0,
CreateView = () => new Views.LauncherWindow(host, services)
});
host.RegisterView(new ModuleView
{
Id = "core.dashboard",
Title = "Dashboard",
// Umbenannt: „Dashboard" heißt jetzt die Startseite; diese Auswertungsansicht
// (KPIs, Diagramme, Tradehistorie) läuft unter „Analytics". Die View-ID bleibt
// bewusst unverändert daran hängen Symbole, Einstellungen und die Gruppierung.
Title = "Analytics",
Group = "Core",
Order = 10,
CreateView = () => new Views.DashboardWindow(
@@ -47,7 +62,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<TradingState>(),
services.GetRequiredService<TerminalLogger>(),
services.GetService<WatchdogHeartbeatService>(),
services.GetService<MullvadVpnService>())
services.GetService<MullvadVpnService>(),
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>())
});
host.RegisterView(new ModuleView
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Modularity;
@@ -10,15 +11,17 @@ namespace PolyTrader.App.Avalonia.Shell
/// Begründung in <c>Views/Modules/README.md</c>: die Modulprojekte bleiben dadurch frei von
/// Avalonia, was für den kopflosen Linux-Betrieb (<c>--headless</c>) den Ausschlag gibt.
///
/// <para>Registriert wird nur, was auch geladen ist: ist ein Modul über
/// <c>DisabledModules</c> abgeschaltet, fehlen seine Dienste im Container dann entfällt auch
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
/// <para>Eine Seite bekommt nur, was auch geladen ist: ist ein Modul über
/// <c>DisabledModules</c> abgeschaltet oder fehlt die Lizenz, fehlen seine Dienste im Container.
/// Ein solches Modul wird dann als <see cref="AvaloniaUiHost.UnavailableView"/> gemeldet und
/// erscheint in der Seitenleiste ausgegraut mit dem Grund lautlos verschwinden darf es nicht.</para>
/// </summary>
public static class ModuleViews
{
public static void Register(IModuleUiHost host, IServiceProvider services)
public static void Register(AvaloniaUiHost host, IServiceProvider services)
{
RegisterIfLoaded(services, "CopyTrading", () => host.RegisterView(new ModuleView
RegisterIfLoaded(host, services, "CopyTrading", "Copytrading", "copytrading", 100,
() => host.RegisterView(new ModuleView
{
Id = "copytrading.main",
Title = "Copytrading",
@@ -33,7 +36,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<Modules.CopyTrading.Persistence.ICopyTradeLogRepository>())
}));
RegisterIfLoaded(services, "ResolutionFarming", () => host.RegisterView(new ModuleView
RegisterIfLoaded(host, services, "ResolutionFarming", "ResolutionFarming", "resolutionfarming", 200,
() => host.RegisterView(new ModuleView
{
Id = "resolutionfarming.main",
Title = "ResolutionFarming",
@@ -48,7 +52,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfSettingsRepository>())
}));
RegisterIfLoaded(services, "Supervisor", () => host.RegisterView(new ModuleView
RegisterIfLoaded(host, services, "Supervisor", "Supervisor", "supervisor", 300,
() => host.RegisterView(new ModuleView
{
Id = "supervisor.main",
Title = "Supervisor",
@@ -63,7 +68,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>())
}));
RegisterIfLoaded(services, "Accounting", () => host.RegisterView(new ModuleView
RegisterIfLoaded(host, services, "Accounting", "Accounting", "accounting", 400,
() => host.RegisterView(new ModuleView
{
Id = "accounting.main",
Title = "Accounting",
@@ -80,12 +86,58 @@ namespace PolyTrader.App.Avalonia.Shell
}));
}
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
private static void RegisterIfLoaded(IServiceProvider services, string moduleName, Action register)
/// <summary>
/// Registriert die Seite, wenn das Modul in dieser Sitzung geladen ist sonst einen
/// ausgegrauten Platzhalter samt Grund.
/// </summary>
private static void RegisterIfLoaded(AvaloniaUiHost host, IServiceProvider services,
string moduleName, string title, string iconKey, int order, Action register)
{
bool loaded = services.GetServices<IPolyTraderModule>()
.Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase));
if (loaded) register();
if (loaded)
{
register();
return;
}
host.RegisterUnavailable(new AvaloniaUiHost.UnavailableView(
title, iconKey, order, BlockerOf(moduleName, services)));
}
/// <summary>
/// Warum fehlt das Modul? Ein modul-eigener Grund (z.B. fehlender API-Key) ist die genaueste
/// Auskunft; sonst bleibt der allgemeine Fall: Lizenz oder <c>DisabledModules</c>.
/// </summary>
private static string BlockerOf(string moduleName, IServiceProvider services)
{
var config = services.GetService<IConfiguration>();
if (config != null)
{
IPolyTraderModule? known = moduleName.ToLowerInvariant() switch
{
"copytrading" => new Modules.CopyTrading.CopyTradingModule(),
"resolutionfarming" => new Modules.ResolutionFarming.ResolutionFarmingModule(),
"supervisor" => new Modules.Supervisor.SupervisorModule(),
"accounting" => new Modules.Accounting.AccountingModule(),
_ => null
};
try
{
string? blocker = known?.GetActivationBlocker(config);
if (!string.IsNullOrWhiteSpace(blocker))
return $"Modul nicht geladen: {blocker}";
}
catch
{
// Die Auskunft ist ein Komfort, kein Muss ein Fehler darin darf den Start
// nicht aufhalten. Dann greift der allgemeine Text.
}
}
return "Modul in dieser Sitzung nicht geladen Lizenz pruefen oder unter " +
"Einstellungen aktivieren (wirkt nach einem Neustart).";
}
}
}
@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows.Input;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using PolyTrader.Core.Modularity;
namespace PolyTrader.App.Avalonia.Shell
{
/// <summary>Schlanker Befehl ohne MVVM-Fremdbibliothek.</summary>
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
public RelayCommand(Action execute) => _execute = execute;
public bool CanExecute(object? parameter) => true;
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged { add { } remove { } }
}
/// <summary>
/// Eine große Kachel auf der Startseite. Früher <c>WindowMenuEntry</c> seit der Umstellung auf
/// die Einfenster-Shell öffnet sie keine Fenster mehr, sondern schaltet die Seite um.
/// </summary>
public sealed class ViewTile
{
public string Title { get; init; } = "";
public string? ToolTip { get; init; }
public Bitmap? Icon { get; init; }
/// <summary>Aktuell angezeigte Seite wird fett dargestellt.</summary>
public bool IsCurrent { get; init; }
/// <summary>Seite wurde in dieser Sitzung schon aufgerufen bekommt einen Haken.</summary>
public bool IsChecked { get; init; }
public ICommand Command { get; init; } = new RelayCommand(() => { });
// Von den Bindungen im XAML genutzt (kein Konverter nötig).
public string FontWeight => IsCurrent ? "Bold" : "Normal";
public string CheckMark => IsChecked ? "✓ " : "";
}
/// <summary>Ein Eintrag der Seitenleiste.</summary>
public sealed class ShellNavEntry
{
public string Title { get; init; } = "";
public string? ToolTip { get; init; }
public Bitmap? Icon { get; init; }
/// <summary>Diese Seite wird gerade angezeigt Hinterlegung im Akzent.</summary>
public bool IsActive { get; init; }
/// <summary>
/// <c>false</c> für bekannte, aber nicht geladene Module: der Eintrag steht ausgegraut da
/// und nennt im Hinweistext den Grund, statt einfach zu fehlen.
/// </summary>
public bool IsAvailable { get; init; } = true;
/// <summary>
/// Beschriftung anzeigen? Steht bewusst am Eintrag und nicht nur am Modell: so kommt die
/// Vorlage mit einer geprüften Bindung auf ihren eigenen Datentyp aus und braucht keinen
/// Umweg über den DataContext des Fensters (der wäre nur ungeprüft bindbar).
/// </summary>
public bool LabelsVisible { get; init; } = true;
/// <summary>Eingeklappte Leiste: nur das Symbol, mittig gesetzt (Klassenbindung im XAML).</summary>
public bool IconOnly => !LabelsVisible;
public ICommand Command { get; init; } = new RelayCommand(() => { });
}
/// <summary>Ein Abschnitt der Seitenleiste („ÜBERSICHT", „MODULE", „SYSTEM").</summary>
public sealed class ShellNavGroup
{
public string Title { get; init; } = "";
public bool LabelsVisible { get; init; } = true;
public IReadOnlyList<ShellNavEntry> Entries { get; init; } = Array.Empty<ShellNavEntry>();
}
/// <summary>
/// Ein Eintrag der Benachrichtigungsleiste über dem Inhalt. Gespeist aus den Warnungen und
/// Fehlern des <c>TerminalLogger</c> dieselbe Quelle wie das Widget „Warnungen &amp; Fehler"
/// auf der Startseite, nur live und wegklickbar.
/// </summary>
public sealed class ShellNotification
{
public string Source { get; init; } = "";
public string Message { get; init; } = "";
/// <summary>Fehler statt Warnung färbt die Kante rot (Klassenbindung im XAML).</summary>
public bool IsError { get; init; }
/// <summary>
/// Setzbar (nicht <c>init</c>), weil der Befehl den Eintrag selbst aus der Liste nimmt und
/// ihn dafür kennen muss er wird direkt nach dem Erzeugen zugewiesen.
/// </summary>
public ICommand Dismiss { get; set; } = new RelayCommand(() => { });
}
/// <summary>
/// Datenmodell der Seitenleiste der Einfenster-Shell. Ersetzt das frühere
/// <c>WindowMenuModel</c>, das auf JEDEM Fenster eine Menüleiste aufgespannt hat.
///
/// <para><b>Layout-Regel:</b> Das Modell liefert nur Daten (<see cref="Groups"/>, Breite,
/// Sichtbarkeit der Beschriftungen). Wie ein Eintrag aussieht, steht deklarativ in
/// <c>Views/ShellWindow.axaml</c> hier wird nichts zur Laufzeit zusammengebaut.</para>
/// </summary>
public sealed class ShellNavModel : INotifyPropertyChanged
{
/// <summary>Abschnitte der Seitenleiste in Anzeigereihenfolge.</summary>
private const string GroupOverview = "ÜBERSICHT";
private const string GroupModules = "MODULE";
private const string GroupSystem = "SYSTEM";
private static readonly string[] GroupOrder = { GroupOverview, GroupModules, GroupSystem };
private readonly AvaloniaUiHost _host;
private bool _expanded = true;
private string _pageTitle = "";
public ObservableCollection<ShellNavGroup> Groups { get; } = new();
public ShellNavModel(AvaloniaUiHost host)
{
_host = host;
Rebuild();
_host.OpenStateChanged += OnOpenStateChanged;
}
/// <summary>Registrierung lösen, wenn die Shell geht kein Event-Leak über die Host-Lebensdauer.</summary>
public void Detach() => _host.OpenStateChanged -= OnOpenStateChanged;
// ===== Seitenleiste ein-/ausklappen =====
public bool IsExpanded => _expanded;
/// <summary>Breite der Seitenleiste. Eingeklappt bleiben nur die Symbole stehen.</summary>
public double SidebarWidth => _expanded ? 236 : 62;
/// <summary>Beschriftungen (Marke, Abschnittstitel, Eintragstexte, Fußzeile) nur ausgeklappt.</summary>
public bool LabelsVisible => _expanded;
public string CollapseGlyph => _expanded ? "" : "";
public string CollapseToolTip => _expanded ? "Seitenleiste einklappen" : "Seitenleiste ausklappen";
public void ToggleSidebar()
{
_expanded = !_expanded;
Raise(nameof(IsExpanded));
Raise(nameof(SidebarWidth));
Raise(nameof(LabelsVisible));
Raise(nameof(CollapseGlyph));
Raise(nameof(CollapseToolTip));
// Die Einträge tragen die Sichtbarkeit ihrer Beschriftung selbst (siehe ShellNavEntry)
// deshalb muss die Liste beim Ein-/Ausklappen neu aufgebaut werden.
Rebuild();
}
// ===== Kopfzeile =====
/// <summary>Titel der angezeigten Seite steht groß in der Kopfzeile.</summary>
public string PageTitle
{
get => _pageTitle;
set { _pageTitle = value; Raise(nameof(PageTitle)); }
}
// ===== Navigation =====
private void OnOpenStateChanged()
{
if (Dispatcher.UIThread.CheckAccess()) Rebuild();
else Dispatcher.UIThread.Post(Rebuild);
}
private void Rebuild()
{
// Geladene Seiten und nicht geladene Module kommen aus zwei Quellen, sollen in der
// Leiste aber gemeinsam nach Order stehen deshalb erst zu einer Liste zusammenführen.
var entries = _host.Views
.Select(v => (
Group: GroupOf(v.Id),
v.Order,
v.Title,
Entry: new ShellNavEntry
{
Title = v.Title,
ToolTip = v.Title,
Icon = ViewIcons.Resolve(v.IconKey),
IsActive = v.Id == _host.ActiveViewId,
LabelsVisible = _expanded,
Command = new RelayCommand(() => _host.OpenView(v.Id))
}))
.Concat(_host.UnavailableViews.Select(u => (
Group: GroupModules,
u.Order,
u.Title,
Entry: new ShellNavEntry
{
Title = u.Title,
ToolTip = u.Reason,
Icon = ViewIcons.Resolve(u.IconKey),
IsAvailable = false,
LabelsVisible = _expanded
})))
.OrderBy(x => x.Order).ThenBy(x => x.Title)
.GroupBy(x => x.Group)
.ToDictionary(g => g.Key, g => g.Select(x => x.Entry).ToList());
Groups.Clear();
foreach (var groupTitle in GroupOrder)
{
if (!entries.TryGetValue(groupTitle, out var groupEntries)) continue;
Groups.Add(new ShellNavGroup
{
Title = groupTitle,
LabelsVisible = _expanded,
Entries = groupEntries
});
}
Raise(nameof(Groups));
}
/// <summary>
/// Ordnet eine View einem Abschnitt der Seitenleiste zu. Bewusst über die View-ID und nicht
/// über <see cref="ModuleView.Group"/>: dort steht der Modulname („CopyTrading"), was in der
/// Seitenleiste zu einem Abschnitt je Modul führen würde.
/// </summary>
private static string GroupOf(string viewId) => viewId switch
{
AvaloniaUiHost.StartViewId or "core.dashboard" => GroupOverview,
"core.jobs" or "core.terminal" or "core.settings" => GroupSystem,
_ => GroupModules
};
private void Raise(string name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
public event PropertyChangedEventHandler? PropertyChanged;
}
}
@@ -17,7 +17,8 @@ namespace PolyTrader.App.Avalonia.Shell
/// <summary>Symbol-Schlüssel → Dateiname in <c>Resources/</c> (als Avalonia-Asset eingebettet).</summary>
private static readonly Dictionary<string, string> FileByKey = new()
{
["dashboard"] = "dashboard.png",
["start"] = "dashboard.png",
["dashboard"] = "token_quantifier.png",
["settings"] = "setting_tools.png",
["terminal"] = "error_log.png",
["jobs"] = "system_time.png",
@@ -30,6 +31,7 @@ namespace PolyTrader.App.Avalonia.Shell
/// <summary>Standard-Symbolschlüssel je View-ID identisch zur WinForms-Shell.</summary>
private static readonly Dictionary<string, string> DefaultKeyByViewId = new()
{
[AvaloniaUiHost.StartViewId] = "start",
["core.dashboard"] = "dashboard",
["core.settings"] = "settings",
["core.terminal"] = "terminal",
@@ -1,133 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using PolyTrader.Core.Modularity;
namespace PolyTrader.App.Avalonia.Shell
{
/// <summary>Schlanker Befehl ohne MVVM-Fremdbibliothek.</summary>
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
public RelayCommand(Action execute) => _execute = execute;
public bool CanExecute(object? parameter) => true;
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged { add { } remove { } }
}
/// <summary>Ein Eintrag der Fenster-Menüleiste (Launcher, eine View, oder die rechte Aktion).</summary>
public sealed class WindowMenuEntry
{
public string Title { get; init; } = "";
public string? ToolTip { get; init; }
public Bitmap? Icon { get; init; }
/// <summary>Aktuelles Fenster wird fett dargestellt.</summary>
public bool IsCurrent { get; init; }
/// <summary>Fenster ist offen (oder das aktuelle) bekommt einen Haken.</summary>
public bool IsChecked { get; init; }
public ICommand Command { get; init; } = new RelayCommand(() => { });
// Von den Bindungen im XAML genutzt (kein Konverter nötig).
public string FontWeight => IsCurrent ? "Bold" : "Normal";
public string CheckMark => IsChecked ? "✓ " : "";
}
/// <summary>
/// Datenmodell der gemeinsamen Fenster-Menüleiste, die auf JEDEM PolyTrader-Fenster erscheint.
///
/// <para><b>Umsetzung der neuen Layout-Regel:</b> Die WinForms-Fassung baute das Menü zur Laufzeit
/// auf (<c>menu.Items.Clear()</c> + dynamisch erzeugte <c>ToolStripMenuItem</c>). Hier liefert das
/// Modell nur noch <see cref="Entries"/>; das Layout steht deklarativ in
/// <c>Controls/WindowMenuBar.axaml</c>.</para>
/// </summary>
public sealed class WindowMenuModel : INotifyPropertyChanged
{
private readonly IModuleUiHost _host;
private readonly string? _currentViewId;
private readonly Action _closeThisWindow;
public ObservableCollection<WindowMenuEntry> Entries { get; } = new();
/// <param name="currentViewId">
/// <c>null</c> für den Launcher (dort heisst die rechte Aktion „Beenden"), sonst die View-ID
/// des Fensters (dann „Fenster schließen").
/// </param>
public WindowMenuModel(IModuleUiHost host, string? currentViewId, Action closeThisWindow)
{
_host = host;
_currentViewId = currentViewId;
_closeThisWindow = closeThisWindow;
Rebuild();
_host.OpenStateChanged += OnOpenStateChanged;
ThemeManager.ThemeChanged += OnOpenStateChanged; // Symbol des Umschalters dreht sich
}
/// <summary>Registrierung lösen, wenn das Fenster geht kein Event-Leak über die Host-Lebensdauer.</summary>
public void Detach()
{
_host.OpenStateChanged -= OnOpenStateChanged;
ThemeManager.ThemeChanged -= OnOpenStateChanged;
}
private void OnOpenStateChanged()
{
if (Dispatcher.UIThread.CheckAccess()) Rebuild();
else Dispatcher.UIThread.Post(Rebuild);
}
private void Rebuild()
{
Entries.Clear();
Entries.Add(new WindowMenuEntry
{
Title = "Launcher",
IsCurrent = _currentViewId == null,
IsChecked = _currentViewId == null,
Command = new RelayCommand(_host.ActivateMain)
});
foreach (var view in _host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
string id = view.Id;
bool isCurrent = id == _currentViewId;
Entries.Add(new WindowMenuEntry
{
Title = view.Title,
Icon = ViewIcons.Resolve(view.IconKey),
IsCurrent = isCurrent,
IsChecked = isCurrent || _host.IsOpen(id),
Command = new RelayCommand(() => _host.OpenView(id))
});
}
// Farbschema-Umschalter: sitzt auf JEDEM Fenster, damit er ueberall erreichbar ist.
// Sonne = ins Helle wechseln, Mond = ins Dunkle.
Entries.Add(new WindowMenuEntry
{
Title = ThemeManager.IsDark ? "☀" : "🌙",
ToolTip = ThemeManager.IsDark ? "Zum hellen Farbschema wechseln" : "Zum dunklen Farbschema wechseln",
Command = new RelayCommand(ThemeManager.Toggle)
});
// Kontextabhaengige rechte Aktion wie in der WinForms-Fassung: nur der Launcher darf die
// App beenden (mit Sicherheitsabfrage), jedes andere Fenster schliesst nur sich selbst.
Entries.Add(_currentViewId == null
? new WindowMenuEntry { Title = "Beenden", Command = new RelayCommand(_host.RequestShutdown) }
: new WindowMenuEntry { Title = "Fenster schließen", Command = new RelayCommand(_closeThisWindow) });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Entries)));
}
public event PropertyChangedEventHandler? PropertyChanged;
}
}
@@ -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
@@ -3,6 +3,20 @@
"_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.",
"MySqlConnectionString": ""
},
"Deploymentcenter": {
"_comment": "Token und LicenseKey liegen in der gitignorierten appsettings.Local.json (Secrets gehören nicht in die versionierte Datei).",
"BaseUrl": "https://dc.mhdf.de",
"ProjectSlug": "polytrader",
"Channel": "prod",
"Token": "",
"LicenseKey": "",
"WatchdogSource": "polytrader",
"WatchdogInstance": "prod-1",
"WatchdogIntervalSeconds": 60,
"WatchdogEnabled": false,
"ErrorReportingEnabled": false,
"UpdateCheckEnabled": false
},
"Logging": {
"LogLevel": {
"Default": "Information",
+53
View File
@@ -0,0 +1,53 @@
{
"_comment": "Gelesen vom Deploymentcenter-Installer (update-agent --action install / --action configure). Siehe docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-5. Fragt NUR ab, was hier noch fehlt: Watchdog-Aktivierung und Lizenzschlüssel liegen bewusst NICHT hier, sondern bleiben Post-Install-Schritte über das Einstellungsfenster (server_settings.xml, maskierte Eingabe, Live-Reload) - setup.json kann nur json/env schreiben, unser server_settings.xml ist XML.",
"schema": 1,
"displayName": "PolyTrader",
"description": "Trading- und Analyse-Suite für Polymarket. Benötigt eine MySQL-Datenbank und einen Deploymentcenter-Zugang für Lizenz, Monitoring und Updates.",
"targets": [
{ "id": "app", "file": "appsettings.json", "location": "install", "format": "json" },
{ "id": "local", "file": "appsettings.Local.json", "location": "install", "format": "json" }
],
"fields": [
{
"key": "Database:MySqlConnectionString",
"label": "MySQL-Verbindung",
"help": "Beispiel: Server=host;Port=3306;Database=polytrader;User ID=user;Password=...;",
"type": "secret",
"required": true,
"target": "local"
},
{
"key": "Deploymentcenter:BaseUrl",
"label": "Deploymentcenter",
"type": "url",
"source": "detect:baseurl",
"target": "app"
},
{
"key": "Deploymentcenter:ProjectSlug",
"label": "Produkt-Slug",
"source": "detect:project",
"target": "app"
},
{
"key": "Deploymentcenter:Channel",
"label": "Auslieferungskanal",
"type": "enum",
"options": ["prod", "beta", "dev"],
"default": "prod",
"required": false,
"target": "app"
},
{
"key": "Deploymentcenter:Token",
"label": "Anwendungstoken dieser Installation",
"help": "Wird automatisch ausgestellt, niemand muss etwas eintippen oder kopieren.",
"source": "provision",
"scopes": ["watchdog:ping", "bugtracker:report"],
"target": "app"
}
]
}
@@ -0,0 +1,60 @@
namespace PolyTrader.Core.Configuration
{
/// <summary>
/// Konfiguration der Deploymentcenter-Integration (Lizenz, Watchdog, Fehler-Reporting,
/// Updates), gebunden an die "Deploymentcenter"-Sektion in <c>appsettings.json</c>.
///
/// Ersetzt die bisherigen Einzeldienste Watchdog (watchdog.mhdf.de) und LicenseLabrador
/// (license.mhdf.de) durch einen gemeinsamen Gegenüber. Siehe
/// docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md.
///
/// <c>Token</c> und <c>LicenseKey</c> sind Geheimnisse und gehören NICHT in die versionierte
/// <c>appsettings.json</c> - dort stehen nur die leeren Platzhalter mit Kommentar, die echten
/// Werte kommen aus der gitignorierten <c>appsettings.Local.json</c> (wie schon
/// <see cref="DatabaseOptions.MySqlConnectionString"/>). Beide dürfen zusätzlich über
/// <c>PolyTrader.Core.Security.SecretProtection</c> verschlüsselt sein (<c>enc:v1:...</c>);
/// Klartext wird unverändert durchgereicht.
/// </summary>
public class DeploymentcenterOptions
{
public const string SectionName = "Deploymentcenter";
/// <summary>Basis-URL des Deploymentcenters, z.B. https://dc.mhdf.de. Nur https:// (oder
/// http:// auf localhost) wird von den aufrufenden Diensten akzeptiert.</summary>
public string BaseUrl { get; set; } = "";
/// <summary>Produkt-Slug im Deploymentcenter-Admin (Entscheidung 14.08.2026: "polytrader").</summary>
public string ProjectSlug { get; set; } = "polytrader";
/// <summary>Auslieferungskanal: prod, beta oder dev.</summary>
public string Channel { get; set; } = "prod";
/// <summary>
/// Anwendungstoken dieser Installation (Scopes watchdog:ping, bugtracker:report).
/// Wird bei der Erstinstallation über setup.json/source:"provision" gezogen; kann
/// alternativ von Hand gesetzt werden. Niemals ein Master-Token hier eintragen.
/// </summary>
public string Token { get; set; } = "";
/// <summary>Lizenzschlüssel dieser Installation.</summary>
public string LicenseKey { get; set; } = "";
/// <summary>Eindeutiger Name dieser Installation im Watchdog-Dashboard.</summary>
public string WatchdogSource { get; set; } = "polytrader";
/// <summary>Mehrere Instanzen derselben Quelle unterscheiden (z.B. "prod-1").</summary>
public string WatchdogInstance { get; set; } = "prod-1";
/// <summary>Sende-Takt des Heartbeats in Sekunden.</summary>
public int WatchdogIntervalSeconds { get; set; } = 60;
/// <summary>Heartbeat an- oder abschalten, ohne den restlichen Block zu leeren.</summary>
public bool WatchdogEnabled { get; set; } = false;
/// <summary>Ungefangene Fehler an /api/errors/v1/report melden.</summary>
public bool ErrorReportingEnabled { get; set; } = false;
/// <summary>Beim Start auf ein neues Release prüfen (installiert nie automatisch).</summary>
public bool UpdateCheckEnabled { get; set; } = false;
}
}
+7 -7
View File
@@ -79,16 +79,16 @@ namespace PolyTraderSharp.Models
[Category("Watchdog Monitoring")]
[DisplayName("Watchdog Enabled")]
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den externen " +
"Watchdog-Server. Bleiben die Heartbeats aus, alarmiert der Watchdog (Dead-Man's-Switch). " +
"Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den Watchdog " +
"des Deploymentcenters. Bleiben die Heartbeats aus, alarmiert der Watchdog " +
"(Dead-Man's-Switch). Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
public bool WatchdogEnabled { get; set; } = false;
[Category("Watchdog Monitoring")]
[DisplayName("Watchdog URL")]
[Description("Basis-URL des Watchdog-Servers, z.B. https://watchdog.example.de. " +
"HTTPS ist Pflicht http:// wird nur für localhost akzeptiert, damit der Agent-Token " +
"nicht im Klartext über das Netz geht.")]
[DisplayName("Deploymentcenter-URL")]
[Description("Basis-URL des Deploymentcenters, z.B. https://dc.mhdf.de (ersetzt den früheren " +
"eigenständigen Watchdog-Server). HTTPS ist Pflicht http:// wird nur für localhost " +
"akzeptiert, damit der Agent-Token nicht im Klartext über das Netz geht.")]
public string WatchdogUrl { get; set; } = "";
/// <summary>
+24 -19
View File
@@ -4,21 +4,26 @@ using System.Collections.Generic;
namespace PolyTrader.Core.Modularity
{
/// <summary>
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View
/// höchstens eine Instanz und holt ein bereits offenes Fenster wieder in den Vordergrund.
/// Eine vom Core oder einem Modul beigesteuerte Ansicht. Die Shell zeigt je View höchstens eine
/// Instanz und holt eine bereits erzeugte wieder nach vorn.
///
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
/// keinen konkreten Fenstertyp. Dadurch bleibt der Core plattformunabhängig (<c>net8.0</c>) und
/// trägt keine WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet:
/// die WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die künftige Avalonia-Shell auf
/// <c>Avalonia.Controls.Window</c>. Module liefern das Fenster, die Shell hängt es auf.</para>
/// keinen konkreten Anzeigetyp. Dadurch bleibt der Core plattformunabhängig und trägt keine
/// WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet: die frühere
/// WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die Avalonia-Shell auf
/// <c>Avalonia.Controls.Control</c>.</para>
///
/// <para><b>Anzeigeform:</b> Die Avalonia-Shell ist seit dem UI-Umbau eine <b>Einfenster-Shell</b>
/// eine View ist dort eine Seite (<c>UserControl</c>) im Inhaltsbereich des einzigen Fensters,
/// kein eigenes Fenster mehr. Der Vertrag hier bleibt davon unberührt; nur die Begriffe „offen"
/// und „Vordergrund" bedeuten jetzt „erzeugt" bzw. „angezeigt".</para>
/// </summary>
public sealed class ModuleView
{
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
/// <summary>Stabile ID für Einzelinstanz-Handling (nur eine Instanz je View).</summary>
public string Id { get; init; } = Guid.NewGuid().ToString();
/// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
/// <summary>Titel der Ansicht in der Avalonia-Shell die Beschriftung in Seitenleiste und Kopfzeile.</summary>
public string Title { get; init; } = "Fenster";
/// <summary>Optionale Gruppierung (z.B. "Core", "CopyTrading").</summary>
@@ -37,7 +42,7 @@ namespace PolyTrader.Core.Modularity
public string? IconKey { get; set; }
/// <summary>
/// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
/// Erzeugt die anzuzeigende Ansicht. Rückgabetyp ist
/// <see cref="object"/> siehe Klassen-Doku zur Toolkit-Neutralität.
/// </summary>
public Func<object> CreateView { get; init; } = () => new object();
@@ -45,9 +50,9 @@ namespace PolyTrader.Core.Modularity
/// <summary>
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
/// Die Shell (Launcher) öffnet die Fenster auf Anforderung und verwaltet den Offen-Status.
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core
/// kennen) das gemeinsame „Fenster"-Menü bauen.
/// Die Shell zeigt eine Ansicht auf Anforderung an und verwaltet ihren Zustand. Über die
/// Navigations-Mitglieder kann JEDE Ansicht (auch Modul-Ansichten, die nur den Core kennen)
/// zu einer anderen wechseln oder das Herunterfahren einleiten.
/// </summary>
public interface IModuleUiHost
{
@@ -56,23 +61,23 @@ namespace PolyTrader.Core.Modularity
/// <summary>Alle registrierten Ansichten (Core + Module).</summary>
IReadOnlyList<ModuleView> Views { get; }
/// <summary>Ist das Fenster dieser View gerade offen?</summary>
/// <summary>Wurde diese View bereits erzeugt (in der Mehrfenster-Fassung: „Fenster ist offen")?</summary>
bool IsOpen(string viewId);
/// <summary>Öffnet die View bzw. holt ein bereits offenes Fenster in den Vordergrund (maximiert).</summary>
/// <summary>Zeigt die View an bzw. holt eine bereits erzeugte wieder nach vorn.</summary>
void OpenView(string viewId);
/// <summary>Holt das Hauptfenster (Launcher) in den Vordergrund.</summary>
/// <summary>Zurück zur Startansicht.</summary>
void ActivateMain();
/// <summary>
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Wird vom „Beenden"-
/// Eintrag JEDES Fensters aufgerufen auch aus Modul-Fenstern, die die App nicht kennen.
/// Die konkrete Shell zeigt den Dialog und fährt bei Bestätigung geordnet herunter.
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Aus jeder Ansicht
/// aufrufbar auch aus Modul-Ansichten, die die App nicht kennen. Die konkrete Shell zeigt
/// den Dialog und fährt bei Bestätigung geordnet herunter.
/// </summary>
void RequestShutdown();
/// <summary>Feuert, wenn sich der Offen-Status irgendeiner View ändert.</summary>
/// <summary>Feuert, wenn eine andere View angezeigt oder erstmals erzeugt wird.</summary>
event Action? OpenStateChanged;
}
}
@@ -20,6 +20,13 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Nethereum.Web3" Version="6.1.0" />
<!-- Deploymentcenter-Integration (Lizenz, Watchdog, Fehler-Reporting, Updates) - siehe
docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md. Lokales Paket in
lib/nuget, keine Projektreferenz ins Nachbar-Repo (PolyTrader muss ohne das
Schwester-Repo bauen, siehe lib/nuget/README.md). Erzeugt beim Bauen zusätzlich die
Klasse PolyTrader.Core.BuildInfo (Version, Git-Commit, Build-Datum) über
Deploymentcenter.BuildInfo.targets, das im Paket unter build/ liegt. -->
<PackageReference Include="Deploymentcenter.Client" Version="2.5.0" />
<!-- Sicherheit: Nethereum 6.1.0 laesst transitiv Newtonsoft.Json [11.0.2, 14.0.0) zu und
wuerde ohne Pinnung auf 11.0.2 aufloesen - das hat eine bekannte Luecke hoher Schwere
(GHSA-5crp-9r3c-p9vr). Die .NET-10-Paketpruefung meldet das jetzt auch fuer transitive
@@ -0,0 +1,29 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace PolyTrader.Core.Security
{
/// <summary>
/// Beschränkt eine Schlüsseldatei auf den Besitzer. Unter Linux/macOS erzeugt
/// <see cref="File.WriteAllText(string, string)"/> mit der üblichen umask sonst <c>644</c> —
/// world-readable, und genau dort liegen Master-Key, Watchdog-Token oder OpenRouter-Key im
/// Klartext. Unter Windows greift die NTFS-Vererbung des Profils, dort ist nichts zu tun.
/// </summary>
public static class FilePermissions
{
public static void RestrictToOwner(string path, Action<string>? onWarning = null)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex)
{
onWarning?.Invoke(
$"Dateirechte für {Path.GetFileName(path)} konnten nicht gesetzt werden: {ex.Message}. Bitte manuell prüfen (chmod 600).");
}
}
}
}
@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Security.Cryptography;
namespace PolyTrader.Core.Security
{
/// <summary>
/// Löst den AES-Master-Key auf, mit dem <see cref="SecretProtection"/> Secrets at-rest
/// verschlüsselt (D-5/D-10 aus dem Deploymentcenter-Umsetzungsplan).
///
/// Reihenfolge: Umgebungsvariable, dann eine vorhandene Schlüsseldatei, sonst wird ein neuer
/// erzeugt und geschrieben — das ist der Regelfall direkt nach <c>update-agent --action install</c>
/// auf einem frischen System, wo niemand von Hand einen Schlüssel eintippt.
///
/// **Die eine Regel, die nie verletzt werden darf:** Erzeugt wird AUSSCHLIESSLICH, wenn weder
/// Variable noch Datei existieren. Ein versehentliches Überschreiben würde jedes bereits
/// verschlüsselte Secret in der Datenbank unwiederbringlich unlesbar machen — der einzige
/// wirklich unumkehrbare Fehler in diesem gesamten Bereich. Ein zweiter Aufruf (jeder weitere
/// Programmstart) liest deshalb ausnahmslos die vorhandene Datei, statt neu zu erzeugen.
/// </summary>
public static class MasterKeyResolver
{
/// <param name="keyFilePath">Pfad der Schlüsseldatei (z.B. neben der Anwendung).</param>
/// <param name="readEnvironmentVariable">Liest z.B. POLYTRADER_MASTER_KEY. Injizierbar für Tests.</param>
/// <param name="onGenerated">Aufgerufen, wenn ein neuer Schlüssel erzeugt wurde (Pfad als Parameter).</param>
/// <param name="onWarning">Aufgerufen bei einem nicht fatalen Problem (z.B. Dateirechte, Erzeugung fehlgeschlagen).</param>
public static string? Resolve(
string keyFilePath,
Func<string?> readEnvironmentVariable,
Action<string>? onGenerated = null,
Action<string>? onWarning = null)
{
string? masterKey = readEnvironmentVariable();
if (!string.IsNullOrWhiteSpace(masterKey))
{
return masterKey;
}
if (File.Exists(keyFilePath))
{
return File.ReadAllText(keyFilePath).Trim();
}
try
{
byte[] keyBytes = RandomNumberGenerator.GetBytes(32); // 256-Bit-Schlüssel
string generated = Convert.ToBase64String(keyBytes);
File.WriteAllText(keyFilePath, generated);
FilePermissions.RestrictToOwner(keyFilePath, onWarning);
onGenerated?.Invoke(keyFilePath);
return generated;
}
catch (Exception ex)
{
// Erzeugung darf den Start nie verhindern - ohne Master-Key laufen Secrets eben
// unverschlüsselt weiter (wie bisher ohne POLYTRADER_MASTER_KEY), statt abzustürzen.
onWarning?.Invoke($"Master-Key konnte nicht automatisch erzeugt werden: {ex.Message}.");
return null;
}
}
}
}
@@ -0,0 +1,22 @@
using System.Text.RegularExpressions;
namespace PolyTrader.Core.Security
{
/// <summary>
/// Entfernt bekannte Secret-Muster aus Text, der an extern gehostete Dienste (Deploymentcenter:
/// Watchdog, Fehler-Reporting) geht. Nur die Musterprüfung selbst — Längenbegrenzung und
/// Zeilenbehandlung entscheidet jeder Aufrufer selbst (eine kurze Statuszeile und ein mehrzeiliger
/// Stacktrace brauchen unterschiedliche Regeln).
/// </summary>
public static class SecretRedactor
{
/// <summary>Entfernt Wallet-Adressen/Keys (0x-Hex) und verschlüsselte Secrets (enc:v1:…).</summary>
public static string RemoveSecrets(string? text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
string s = Regex.Replace(text, "0x[0-9a-fA-F]{8,}", "0x…");
s = Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…");
return s;
}
}
}
@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Security;
namespace PolyTraderSharp.Services
{
/// <summary>
/// Meldet ungefangene Fehler an den Fehler-Stream des Deploymentcenters (D-3,
/// <c>POST /api/errors/v1/report</c>). Fängt drei Quellen ab: <see cref="AppDomain.UnhandledException"/>
/// (der Prozess stirbt gerade <c>level: "fatal"</c>), <see cref="TaskScheduler.UnobservedTaskException"/>
/// (ein Hintergrund-Task ist fehlgeschlagen, ohne dass jemand hinsah <c>"warning"</c>) und
/// <see cref="TerminalLogger.OnLogMessage"/> bei <see cref="LogLevel.Error"/> (<c>"error"</c>).
///
/// **Grundregel wie beim Watchdog:** Ein nicht erreichbares Deploymentcenter darf PolyTrader nie
/// beeinträchtigen. Zusätzlich hier besonders wichtig: **keine Rückkopplung.** Der Reporter selbst
/// darf einen Sendefehler NIEMALS über <see cref="TerminalLogger.Error"/> melden das würde exakt
/// den Log-Kanal auslösen, den er selbst abhört, und sich damit endlos selbst melden. Eigene
/// Fehlermeldungen laufen deshalb ausschließlich über <see cref="TerminalLogger.Warning"/>.
///
/// Fehlermeldungen enthalten möglicherweise Wallet-Adressen, Marktkennungen oder Beträge; der
/// Deploymentcenter-Server liegt beim Hoster. Nachricht und Stacktrace laufen deshalb vor dem
/// Versand durch <see cref="SecretRedactor"/>.
/// </summary>
public sealed class DeploymentcenterErrorReporter : BackgroundService
{
/// <summary>
/// Eigene, deutlich strengere Grenze als das serverseitige Limit (60/Minute/IP) ein
/// durchdrehender Fehlerpfad soll lokal gedrosselt werden, bevor er den Server überhaupt
/// erreicht oder mit anderen Diensten auf derselben IP kollidiert.
/// </summary>
private const int MaxReportsPerMinute = 20;
private const int MaxStackTraceLength = 4000;
private readonly TerminalLogger _logger;
private readonly DeploymentcenterOptions _options;
private readonly HttpClient _http;
private readonly object _rateLock = new();
private readonly Queue<DateTime> _recentSendsUtc = new();
private bool _throttleWarningLogged;
public DeploymentcenterErrorReporter(
TerminalLogger logger,
DeploymentcenterOptions options,
HttpClient? httpClient = null)
{
_logger = logger;
_options = options;
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
{
Timeout = TimeSpan.FromSeconds(10)
};
_logger.OnLogMessage += OnAppLog;
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
/// <summary>Rein ereignisgesteuert kein eigener Takt nötig, nur Auf- und Abbau der Abonnements.</summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Normales Herunterfahren.
}
}
public override void Dispose()
{
_logger.OnLogMessage -= OnAppLog;
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
base.Dispose();
}
internal void OnAppLog(object? sender, LogMessageEventArgs e)
{
if (e.Level != LogLevel.Error) return;
// Fire-and-forget: OnLogMessage ist synchron, darf den loggenden Thread nicht blockieren.
// ReportAsync fängt jede eigene Ausnahme selbst ab (No-Throw-Vertrag) hier entsteht also
// keine unbeobachtete Task-Exception, die wiederum den UnobservedTaskException-Zweig
// triggern und sich selbst melden würde.
_ = ReportAsync("ApplicationError", e.Message, stackTrace: null, level: "error");
}
internal void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
string message = (e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString() ?? "Unbekannter Fehler";
string? stack = (e.ExceptionObject as Exception)?.StackTrace;
string type = (e.ExceptionObject as Exception)?.GetType().FullName ?? "UnknownException";
// Der Prozess beendet sich gleich (IsTerminating in aller Regel true) - synchron und mit
// kurzer Frist senden, statt auf einen Fire-and-forget-Task zu setzen, der nie mehr laufen
// könnte.
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
ReportAsync(type, message, stack, "fatal", cts.Token).GetAwaiter().GetResult();
}
catch { /* Der Prozess stirbt ohnehin - hier ist nichts mehr zu retten. */ }
}
internal void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
var ex = e.Exception?.Flatten().InnerException ?? (Exception?)e.Exception;
_ = ReportAsync(ex?.GetType().FullName ?? "UnobservedTaskException",
ex?.Message ?? "Unbeobachtete Task-Exception", ex?.StackTrace, "warning");
// Verhindert, dass .NET den Prozess wegen der unbeobachteten Exception beendet - wir haben
// sie ja gerade beobachtet und gemeldet.
e.SetObserved();
}
/// <summary>
/// Meldet einen Fehler. Wirft nie, meldet eigene Fehlschläge nur über
/// <see cref="TerminalLogger.Warning"/> (siehe Klassendokumentation zur Rückkopplung).
/// </summary>
public async Task<bool> ReportAsync(
string exceptionType, string message, string? stackTrace, string level, CancellationToken ct = default)
{
try
{
if (!_options.ErrorReportingEnabled) return false;
if (string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.Token))
return false;
if (!TryBuildEndpoint(_options.BaseUrl, out Uri? uri, out string urlProblem))
{
WarnOnce($"⚠️ Fehler-Reporting nicht sendebereit: {urlProblem}");
return false;
}
if (!TryReserveRateSlot())
{
if (!_throttleWarningLogged)
{
_logger.Warning("⚠️ Fehler-Reporting lokal gedrosselt (mehr als " +
$"{MaxReportsPerMinute} Meldungen/Minute) weitere Meldungen werden " +
"verworfen, bis sich die Rate beruhigt. Details bleiben im Terminal-Log.");
_throttleWarningLogged = true;
}
return false;
}
string token;
try { token = SecretProtection.Unprotect(_options.Token); }
catch (Exception ex) { WarnOnce($"⚠️ Fehler-Reporting-Token nicht lesbar: {ex.Message}"); return false; }
var payload = new
{
project_slug = _options.ProjectSlug,
exception = exceptionType,
message = WatchdogHeartbeatService.Sanitize(message),
stack_trace = SanitizeStackTrace(stackTrace),
level,
build = BuildInfo.Version,
environment = Environment.GetEnvironmentVariable("POLYTRADER_ENVIRONMENT") ?? "production"
};
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
WarnOnce($"⚠️ Fehler-Reporting fehlgeschlagen: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
return false;
}
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
// Letztes Sicherheitsnetz - siehe Klassendokumentation: NIE über .Error loggen.
WarnOnce($"⚠️ Fehler-Reporting unerwartet fehlgeschlagen: {ex.Message}");
return false;
}
}
/// <summary>Entprellt Warnungen wie der Watchdog: der erste Fehlschlag wird gemeldet, nicht jeder einzelne.</summary>
private bool _lastSendFailed;
private void WarnOnce(string message)
{
if (_lastSendFailed) return;
_logger.Warning(message);
_lastSendFailed = true;
}
/// <summary>Gleitendes Ein-Minuten-Fenster, ohne Hintergrund-Timer.</summary>
private bool TryReserveRateSlot()
{
lock (_rateLock)
{
DateTime now = DateTime.UtcNow;
while (_recentSendsUtc.Count > 0 && (now - _recentSendsUtc.Peek()) > TimeSpan.FromMinutes(1))
_recentSendsUtc.Dequeue();
if (_recentSendsUtc.Count >= MaxReportsPerMinute) return false;
_recentSendsUtc.Enqueue(now);
_throttleWarningLogged = false; // erneutes Überschreiten wieder genau einmal melden
return true;
}
}
/// <summary>
/// Stacktraces dürfen (anders als die kurze Statuszeile im Heartbeat) mehrzeilig bleiben
/// sie sind sonst wertlos. Nur die bekannten Secret-Muster werden entfernt, dazu eine
/// großzügige Längenbegrenzung gegen ausufernde Payloads.
/// </summary>
private static string? SanitizeStackTrace(string? stackTrace)
{
if (string.IsNullOrEmpty(stackTrace)) return null;
string s = SecretRedactor.RemoveSecrets(stackTrace);
return s.Length > MaxStackTraceLength ? s.Substring(0, MaxStackTraceLength - 1) + "…" : s;
}
private static bool TryBuildEndpoint(string baseUrl, out Uri? uri, out string problem)
{
uri = null;
problem = string.Empty;
if (!Uri.TryCreate(baseUrl?.Trim().TrimEnd('/'), UriKind.Absolute, out Uri? parsed))
{
problem = $"Deploymentcenter-URL ist keine gültige absolute URL: '{baseUrl}'.";
return false;
}
bool isHttps = parsed.Scheme == Uri.UriSchemeHttps;
bool isLocalHttp = parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback;
if (!isHttps && !isLocalHttp)
{
problem = $"Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{baseUrl}'.";
return false;
}
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/errors/v1/report");
return true;
}
}
}
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Core;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
@@ -13,16 +16,25 @@ namespace PolyTraderSharp.Services
public sealed record WatchdogSendResult(bool Success, string Detail);
/// <summary>
/// Sendet im festen Takt einen Heartbeat an den externen Watchdog-Server (Dead-Man's-Switch).
/// Bleiben die Heartbeats aus, alarmiert der Watchdog er läuft bewusst auf einer anderen
/// Maschine und bemerkt damit auch einen harten Server-Crash.
/// Sendet im festen Takt einen Heartbeat an den Watchdog des Deploymentcenters
/// (Dead-Man's-Switch). Bleiben die Heartbeats aus, alarmiert der Watchdog er läuft bewusst
/// auf einer anderen Maschine und bemerkt damit auch einen harten Server-Crash.
///
/// **Umstellung 14.08.2026:** Ersetzt den eigenständigen Watchdog-Dienst (watchdog.mhdf.de) und
/// LicenseLabrador durch das Deploymentcenter (siehe
/// docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-1). Endpunkte
/// sind jetzt <c>/api/watchdog/v1/ping</c> und <c>/api/watchdog/v1/event</c>, Auth-Header
/// <c>Authorization: Bearer</c> statt <c>X-Watchdog-Key</c>. <see cref="ServerSettings.WatchdogUrl"/>
/// und <see cref="ServerSettings.WatchdogToken"/> zeigen ab jetzt auf das Deploymentcenter statt
/// auf den alten Einzeldienst UI, Maskierung und Live-Reload bleiben unverändert, es ist
/// dieselbe erprobte Mechanik (21+ Tests), nur mit neuem Gegenüber.
///
/// **Grundregel:** Ein Ausfall des Watchdogs darf PolyTrader NIEMALS beeinträchtigen. Deshalb
/// ist jeder Sendeversuch vollständig gekapselt; aus <see cref="ExecuteAsync"/> darf keine
/// Ausnahme entweichen (ein werfender BackgroundService würde den Host beenden).
///
/// Bewusst eine eigene Implementierung statt des mitgelieferten Watchdog-Testclients: dieser
/// deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und
/// Bewusst eine eigene Implementierung statt des mitgelieferten Deploymentcenter-Testclients:
/// dieser deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und
/// <c>http://</c> ist nur für localhost erlaubt, damit der Agent-Token nicht im Klartext
/// über das Netz geht.
/// </summary>
@@ -65,6 +77,16 @@ namespace PolyTraderSharp.Services
private string? _lastAppErrorMessage;
private bool _startedEventSent;
/// <summary>
/// D-1: Selbst ermittelte Gesundheitsprüfungen (z.B. VPN verbunden, DB erreichbar), die der
/// Deploymentcenter-Watchdog unter "checks" auswertet ein als "ok" gemeldeter Heartbeat mit
/// fehlschlagender Prüfung wird dort automatisch auf "warning" herabgestuft. Der Service
/// interpretiert die Namen nicht selbst, er sammelt nur ein. Composition Root (Program.cs)
/// registriert die konkreten Prüfungen.
/// </summary>
private readonly Dictionary<string, Func<(bool ok, string? message)>> _checks = new();
private readonly object _checksLock = new();
public WatchdogHeartbeatService(
TerminalLogger logger,
HttpClient? httpClient = null,
@@ -103,6 +125,53 @@ namespace PolyTraderSharp.Services
/// <summary>Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster).</summary>
public void ReloadSettings() => _settings = _settingsLoader();
/// <summary>
/// D-1: Registriert eine benannte Gesundheitsprüfung, die bei jedem Heartbeat ausgewertet
/// wird. Eine zweite Registrierung unter demselben Namen ersetzt die vorherige. Der Aufruf
/// selbst darf nicht werfen ein Fehler in einer Prüfung führt sonst nur dazu, dass diese
/// eine Prüfung als "nicht ok" gemeldet wird, nie dazu, dass der Heartbeat ausfällt.
/// </summary>
public void RegisterCheck(string name, Func<(bool ok, string? message)> check)
{
lock (_checksLock) { _checks[name] = check; }
}
/// <summary>Entfernt eine zuvor registrierte Prüfung (z.B. wenn ein Dienst abgeschaltet wird).</summary>
public void UnregisterCheck(string name)
{
lock (_checksLock) { _checks.Remove(name); }
}
/// <summary>Wertet alle registrierten Prüfungen aus. Liefert <c>null</c>, wenn keine registriert sind.</summary>
private Dictionary<string, object>? EvaluateChecks()
{
Dictionary<string, Func<(bool ok, string? message)>> snapshot;
lock (_checksLock)
{
if (_checks.Count == 0) return null;
snapshot = new Dictionary<string, Func<(bool ok, string? message)>>(_checks);
}
var result = new Dictionary<string, object>();
foreach (var (name, check) in snapshot)
{
try
{
var (ok, message) = check();
result[name] = message == null
? new { ok }
: new { ok, message = Sanitize(message) };
}
catch (Exception ex)
{
// Eine werfende Prüfung darf den Heartbeat nicht zum Absturz bringen sie
// meldet sich selbst als fehlgeschlagen.
result[name] = new { ok = false, message = $"Prüfung fehlgeschlagen: {ex.Message}" };
}
}
return result;
}
/// <summary>
/// Prüft die aktuelle Konfiguration. <c>null</c> = sendebereit, sonst ein kurzer,
/// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt.
@@ -115,7 +184,7 @@ namespace PolyTraderSharp.Services
return "Kein Agent-Token konfiguriert.";
if (string.IsNullOrWhiteSpace(_settings.WatchdogSource))
return "Keine Source konfiguriert.";
if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out _, out string urlProblem))
if (!TryBuildEndpoint(_settings.WatchdogUrl, PingPath, out _, out string urlProblem))
return urlProblem;
return null;
}
@@ -176,7 +245,7 @@ namespace PolyTraderSharp.Services
// den Programmstart; danach übernimmt die Staleness-Erkennung).
if (!_startedEventSent)
{
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{AppVersion})", stoppingToken)
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{BuildInfo.Version})", stoppingToken)
.ConfigureAwait(false);
_startedEventSent = true;
}
@@ -206,9 +275,12 @@ namespace PolyTraderSharp.Services
}
/// <summary>
/// B.3: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende („stopped_graceful"),
/// damit dieser NICHT fälschlich einen Absturz alarmiert. Best effort mit kurzem Timeout
/// blockiert das Beenden nicht nennenswert.
/// B.3/D-1: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende. Zwei Wege, weil sie
/// unterschiedliche Dinge tun: der Heartbeat mit <c>status:"stopped"</c> ist das, was der
/// Evaluator auswertet (er lässt einen "stopped"-Monitor in Ruhe, statt ihn nach dem
/// Ausbleiben weiterer Heartbeats als "down" zu alarmieren); das Event "stopped_graceful"
/// landet zusätzlich im Ereignisprotokoll. Best effort mit kurzem Timeout blockiert das
/// Beenden nicht nennenswert.
/// </summary>
public override async Task StopAsync(CancellationToken cancellationToken)
{
@@ -216,7 +288,11 @@ namespace PolyTraderSharp.Services
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// 5 s statt 3 s: zwei Aufrufe teilen sich das Budget, keiner soll dadurch
// praktisch ohne Zeit dastehen. host.StopAsync erlaubt insgesamt bis zu 30 s.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await SendHeartbeatAsync("stopped", "PolyTrader wird geordnet beendet.", cts.Token)
.ConfigureAwait(false);
await SendEventAsync("stopped_graceful", "info", "PolyTrader wird geordnet beendet.", cts.Token)
.ConfigureAwait(false);
}
@@ -242,7 +318,7 @@ namespace PolyTraderSharp.Services
string? problem = GetConfigurationProblem();
if (problem != null) return new WatchdogSendResult(false, problem);
if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out Uri? uri, out string urlProblem))
if (!TryBuildEndpoint(_settings.WatchdogUrl, PingPath, out Uri? uri, out string urlProblem))
return new WatchdogSendResult(false, urlProblem);
string token;
@@ -268,7 +344,11 @@ namespace PolyTraderSharp.Services
// "interval"/"expected_interval_sec" und ignoriert "intervalSeconds" still.
interval = EffectiveIntervalSeconds(),
group = "Applications",
metadata = new { version = AppVersion }
os = RuntimeInformation.OSDescription,
// D-1: aus BuildInfo statt aus der Assembly-Version - stimmt jetzt mit
// Directory.Build.props <Version> überein statt stets 1.0.0.0 zu tragen.
version = BuildInfo.Version,
checks = EvaluateChecks()
};
try
@@ -277,7 +357,9 @@ namespace PolyTraderSharp.Services
{
Content = JsonContent.Create(payload)
};
request.Headers.Add("X-Watchdog-Key", token);
// D-1: Authorization: Bearer statt X-Watchdog-Key - das Deploymentcenter kennt den
// alten Header nicht.
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
@@ -330,15 +412,15 @@ namespace PolyTraderSharp.Services
}
/// <summary>
/// B.3: Sendet ein Lifecycle-Event an <c>/api/event</c> (z. B. „started", „stopped_graceful").
/// Best effort wirft nie und blockiert das Herunterfahren nicht.
/// B.3: Sendet ein Lifecycle-Event an <c>/api/watchdog/v1/event</c> (z. B. „started",
/// „stopped_graceful"). Best effort wirft nie und blockiert das Herunterfahren nicht.
/// </summary>
public async Task<WatchdogSendResult> SendEventAsync(string kind, string severity, string? message, CancellationToken ct = default)
{
string? problem = GetConfigurationProblem();
if (problem != null) return new WatchdogSendResult(false, problem);
if (!TryBuildEndpoint(_settings.WatchdogUrl, "event", out Uri? uri, out string urlProblem))
if (!TryBuildEndpoint(_settings.WatchdogUrl, EventPath, out Uri? uri, out string urlProblem))
return new WatchdogSendResult(false, urlProblem);
string token;
@@ -357,7 +439,7 @@ namespace PolyTraderSharp.Services
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
request.Headers.Add("X-Watchdog-Key", token);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
return response.IsSuccessStatusCode
? new WatchdogSendResult(true, $"HTTP {(int)response.StatusCode}")
@@ -390,9 +472,10 @@ namespace PolyTraderSharp.Services
/// </summary>
internal static string Sanitize(string? message)
{
if (string.IsNullOrEmpty(message)) return string.Empty;
string s = System.Text.RegularExpressions.Regex.Replace(message, "0x[0-9a-fA-F]{8,}", "0x…");
s = System.Text.RegularExpressions.Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…");
// D-3: Musterprüfung selbst liegt jetzt in SecretRedactor - gemeinsam mit dem
// Fehler-Reporter genutzt, der dieselben Muster aber ohne Zeilen-/Längenlimit braucht
// (ein Stacktrace ist von Natur aus mehrzeilig und soll lesbar bleiben).
string s = SecretRedactor.RemoveSecrets(message);
s = s.Replace("\r", " ").Replace("\n", " ").Trim();
return s.Length > 200 ? s.Substring(0, 197) + "…" : s;
}
@@ -401,11 +484,17 @@ namespace PolyTraderSharp.Services
private int EffectiveIntervalSeconds() =>
Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds);
/// <summary>D-1: Pfad des Heartbeat-Endpunkts im Deploymentcenter (vorher "/api/heartbeat").</summary>
internal const string PingPath = "watchdog/v1/ping";
/// <summary>D-1: Pfad des Event-Endpunkts im Deploymentcenter (vorher "/api/event").</summary>
internal const string EventPath = "watchdog/v1/event";
/// <summary>
/// Baut die Ziel-URL und erzwingt TLS. <c>http://</c> ist ausschließlich für localhost
/// erlaubt (lokales Testen); sonst würde der Agent-Token im Klartext übertragen.
/// </summary>
internal static bool TryBuildEndpoint(string baseUrl, string action, out Uri? uri, out string problem)
internal static bool TryBuildEndpoint(string baseUrl, string relativePath, out Uri? uri, out string problem)
{
uri = null;
problem = string.Empty;
@@ -425,12 +514,8 @@ namespace PolyTraderSharp.Services
return false;
}
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{action}");
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{relativePath}");
return true;
}
/// <summary>Version der laufenden Assembly landet als Metadatum im Heartbeat.</summary>
private static string AppVersion =>
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
}
}