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
+3
View File
@@ -33,6 +33,9 @@ MongoDB/
master.key master.key
openrouter.key openrouter.key
# Packager-Zugangsdaten (FTP, updateservice:publish-Token) Vorlage in deploy/packager.config.example.json
deploy/packager.config.json
# ── Logs & temporäre Dateien ───────────────────── # ── Logs & temporäre Dateien ─────────────────────
*.log *.log
*.tmp *.tmp
+23
View File
@@ -0,0 +1,23 @@
<Project>
<!--
Eine Versionsnummer für alle Projekte der Solution.
Vorher stand in keiner einzigen .csproj ein <Version> - jede Assembly trug
stillschweigend 1.0.0.0. Für den Deploymentcenter-UpdateService ist das
kein kosmetischer Mangel: der Packager prüft den Aufrufparameter "version"
gegen die Hauptassembly und bricht bei Abweichung ab (aus gutem Grund: wird 1.0.1
als 1.0.2 veröffentlicht, aktualisieren alle Clients, melden aber weiter
1.0.1, halten das Release erneut für neu und aktualisieren bei jedem Start
wieder). Eine zentrale Version verhindert genau das: <Version> steht ab
jetzt an genau einer Stelle, nicht verstreut über mehrere Projektdateien,
die sonst früher oder später auseinanderlaufen.
Hochzählen bei jedem Release, siehe
docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md (D-4).
-->
<PropertyGroup>
<Version>0.1.0</Version>
</PropertyGroup>
</Project>
+10 -6
View File
@@ -3,19 +3,23 @@
<packageSources> <packageSources>
<clear /> <clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<!-- Lokaler Feed für Pakete aus unseren eigenen Schwester-Projekten (LicenseLabrador). <!-- Lokaler Feed für Pakete aus unseren eigenen Schwester-Projekten. Bewusst ein Paket im
Bewusst ein Paket im Repo statt einer Projektreferenz nach ..\..\LicenseLabrador: Repo statt einer Projektreferenz nach ..\..\Deploymentcenter bzw. ..\..\LicenseLabrador:
PolyTrader muss eigenständig bauen, ohne dass das Schwester-Repo daneben liegt. PolyTrader muss eigenständig bauen, ohne dass das Schwester-Repo daneben liegt.
Neues SDK übernehmen: siehe lib/nuget/README.md. --> Neues SDK übernehmen: siehe lib/nuget/README.md.
LicenseLabrador.Client wird mit der Deploymentcenter-Integration abgelöst (siehe
docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-6) und
erst nach der Live-Abnahme von Watchdog/Lizenz entfernt. -->
<add key="local" value="lib/nuget" /> <add key="local" value="lib/nuget" />
</packageSources> </packageSources>
<!-- Source-Mapping verhindert Dependency Confusion: unsere LicenseLabrador.*-Pakete werden <!-- Source-Mapping verhindert Dependency Confusion: unsere eigenen Pakete werden ausschließlich
ausschließlich lokal aufgelöst und können nicht von einem gleichnamigen Paket auf lokal aufgelöst und können nicht von einem gleichnamigen Paket auf nuget.org verdrängt
nuget.org verdrängt werden. Alles andere kommt weiterhin von nuget.org. --> werden. Alles andere kommt weiterhin von nuget.org. -->
<packageSourceMapping> <packageSourceMapping>
<clear /> <clear />
<packageSource key="local"> <packageSource key="local">
<package pattern="LicenseLabrador.*" /> <package pattern="LicenseLabrador.*" />
<package pattern="Deploymentcenter.*" />
</packageSource> </packageSource>
<packageSource key="nuget.org"> <packageSource key="nuget.org">
<package pattern="*" /> <package pattern="*" />
+14
View File
@@ -3,6 +3,20 @@
"_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.", "_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.",
"MySqlConnectionString": "" "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": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
+40
View File
@@ -0,0 +1,40 @@
{
"_comment": "Kopie als packager.config.json in diesem Ordner anlegen und ausfuellen. packager.config.json ist per .gitignore ausgeschlossen. Alternativ ueber Umgebungsvariablen: DC_FTP_HOST, DC_FTP_PORT, DC_FTP_USER, DC_FTP_PASS, DC_API_URL, DC_TOKEN. Siehe docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-4.",
"ftpHost": "ftp.example.com",
"ftpPort": 21,
"ftpUser": "ftp-user",
"ftpPass": "ftp-password",
"ftpRemoteBaseDir": "/public_html/releases",
"apiBaseUrl": "https://dc.mhdf.de",
"_apiToken_comment": "Token mit dem Recht updateservice:publish. Im Deploymentcenter-WebUI unter Token-Verwaltung erzeugen - NICHT das Dev-Sub-Token aus appsettings.Local.json wiederverwenden (das hat nur watchdog:ping/bugtracker:report).",
"apiToken": "",
"_excludePatterns_comment": "Kommt gar nicht ins Paket. D-6 des Umsetzungsplans: appsettings.Local.json enthaelt das MySQL-Passwort im Klartext, server_settings.xml den Watchdog-Token und den Lizenzschluessel, master.key den AES-Master-Key aller verschluesselten Secrets. Der Packager bricht seit Version 2.5.0 zusaetzlich bei erkannten Zugangsdaten ab (Datei- UND Inhaltspruefung) - diese Liste ist die erste, nicht die einzige Verteidigungslinie.",
"excludePatterns": [
"appsettings.Local.json",
"appsettings.*.Local.json",
"*.local.json",
"master.key",
"openrouter.key",
"server_settings.xml",
"*.pfx",
"*.key",
"*.pem",
"*.p12",
"*.db",
"*.sqlite",
"*.sqlite3",
"Logs/**",
"*.pdb",
"*.xml",
"*.log",
"scratch/**"
],
"_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber niemals eine vorhandene Datei. appsettings.json enthaelt den Deploymentcenter-Konfigurationsblock (BaseUrl, ProjectSlug, ...) - eine Erstinstallation bekommt die Vorlage, ein Update laesst die vor Ort eingerichteten Werte in Ruhe.",
"preservePatterns": [
"appsettings.json"
]
}
+39
View File
@@ -0,0 +1,39 @@
# systemd-Unit für den kopflosen Linux-Betrieb (--headless, Stufe L2 der Linux-Portierung).
# Der Deploymentcenter-Installer legt selbst keinen Dienst an (siehe SETUP_INTEGRATION_GUIDE §7,
# "Dienstregistrierung fehlt") - diese Datei ist unser Ersatz dafür.
#
# Einrichtung:
# sudo cp deploy/polytrader.service /etc/systemd/system/polytrader.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now polytrader
#
# WICHTIG (D-11 aus dem Umsetzungsplan): server_settings.xml wird relativ zum Arbeitsverzeichnis
# geladen, master.key dagegen relativ zur ausführbaren Datei (AppContext.BaseDirectory). Beides
# fällt nur zusammen, wenn WorkingDirectory exakt das Installationsverzeichnis ist - deshalb hier
# ausdrücklich gesetzt statt dem systemd-Standard (Root des Dienstbenutzers) überlassen. Eine
# abweichende WorkingDirectory liest server_settings.xml aus dem falschen Ordner, ohne dass das
# irgendwo auffällt (ServerSettings.Load() legt bei fehlender Datei kommentarlos eine neue an).
[Unit]
Description=PolyTrader (Trading- und Analyse-Suite fuer Polymarket)
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=simple
# Anpassen: Installationsverzeichnis und Dienstkonto. Nicht als root betreiben - das
# Installationsverzeichnis (inkl. master.key, server_settings.xml, data.db) gehört diesem Konto.
User=polytrader
Group=polytrader
WorkingDirectory=/opt/polytrader
ExecStart=/opt/polytrader/PolyTrader.App.Avalonia --headless
Restart=on-failure
RestartSec=10
# Absturz vom sauberen Beenden unterscheiden: SIGTERM statt SIGKILL, mit Frist fuer den
# geordneten Shutdown-Pfad (offene Positionen, Watchdog-"stopped_graceful", DB-Flush).
KillSignal=SIGTERM
TimeoutStopSec=45
[Install]
WantedBy=multi-user.target
+9
View File
@@ -7,6 +7,15 @@
> **Lizenzverwaltung, Watchdog und Error-Reporting** grundlegend. Alles, was in diesem Dokument zu > **Lizenzverwaltung, Watchdog und Error-Reporting** grundlegend. Alles, was in diesem Dokument zu
> LicenseLabrador oder zum Watchdog-Heartbeat steht, ist damit Übergangszustand — dort nichts mehr > LicenseLabrador oder zum Watchdog-Heartbeat steht, ist damit Übergangszustand — dort nichts mehr
> ausbauen. > ausbauen.
>
> **P3c hat seit dem 14.08.2026 einen eigenen Plan:**
> **[UMSETZUNGSPLAN-Deploymentcenter-Integration.md](./umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md)**
> — er ist ab jetzt maßgeblich und korrigiert zwei Aussagen dieses Dokuments:
> §5.2 unterschätzt die Watchdog-Umstellung („nur Header und URL"; tatsächlich kommen `version`,
> `os`, `checks`, `metrics` und `status: "stopped"` dazu), und §5.2 wie Risiko **R6** stützen die
> headless-Lizenzprüfung auf `ILicensePrompt` aus dem SDK — diese Schnittstelle wird dort jedoch
> **nirgends benutzt** und trägt nichts. Der Umfang liegt bei **47 PT** statt 24 PT, weil
> Error-Reporting, Auslieferung und Erstinstallation hier noch nicht enthalten waren.
--- ---
@@ -0,0 +1,816 @@
# Umsetzungsplan: Deploymentcenter in PolyTrader einbinden (P3c)
> Stand: 14.08.2026 · Grundlage: `J:\Softwareprojekte\Deploymentcenter\docs` (Fassung 2.4) und die
> SDK-Quellen unter `client-dotnet\Deploymentcenter.Client`.
> Löst ab: [UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md](./UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md)
> (Watchdog + LicenseLabrador als Einzeldienste).
> Ersetzt in `ANALYSE-Linux-Portierung.md` den Punkt **P3c** und präzisiert dort §5.2.
---
## 0. Was sich gegenüber der bisherigen Planung geändert hat
Das Deploymentcenter ist nicht mehr „Lizenz + Watchdog an einem Ort", sondern deckt inzwischen
**vier** Bereiche ab. Zwei davon standen in unserer Planung bisher gar nicht:
| Bereich | Zustand bei uns |
|---|---|
| **Lizenz** | vorhanden, aber gegen LicenseLabrador gebaut (`LicenseGate`, `Ui/LicenseDialog`) — **und in der Avalonia-App gar nicht verdrahtet**, dort läuft derzeit alles ungeprüft |
| **Watchdog** | vorhanden (`WatchdogHeartbeatService`), aber gegen die **alte** Watchdog-API gebaut — URL, Header und Payload passen nicht |
| **Error-Reporting / Bugtracker** | **existiert bei uns nicht**. Es gibt im gesamten Repo weder `AppDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` |
| **UpdateService + Erstinstallation** | **existiert bei uns nicht**. Kein Paket, kein Release-Weg, keine Versionsdisziplin (`<Version>` steht in keiner einzigen `.csproj`) |
Die Schätzung aus der Analyse (24 PT) bezog sich auf die ersten beiden Punkte. Mit
Error-Reporting, Auslieferung und Erstinstallation liegt der Umfang bei **47 PT**.
---
## 1. Befunde — Probleme, Bugs und Konzeptfehler
Sortiert nach Wirkung auf uns. Spalte „Wo" trennt, was **wir** lösen und was im
**Deploymentcenter** gelöst werden müsste.
### D-1 · Der Update-Weg beendet die Anwendung hart — für einen Trading-Bot unzulässig · **hoch** · Wo: wir + DC
`UpdateClient.LaunchUpdateAgent` ruft `Environment.Exit(0)`
(`client-dotnet/Deploymentcenter.Client/UpdateClient.cs:401`), und `exitCurrentApp: true` ist die
**Vorgabe**. Genau das haben wir bei der Lizenz schon einmal ausgeschlossen: Eine Anwendung mit
offenen Positionen, laufenden CLOB-Aufrufen und einem 10-Sekunden-Bestätigungsdialog darf nicht
mitten im Lauf abgeschossen werden.
Lösbar auf unserer Seite (`exitCurrentApp: false` und danach der bestehende geordnete
Shutdown-Pfad). **Nicht lösbar auf unserer Seite:** Der Agent wartet über `--wait-for-pid` auf das
Ende der Anwendung, aber nur `--wait-timeout` Sekunden — Vorgabe **60**
(`Deploymentcenter.UpdateAgent/Program.cs:70`). `LaunchUpdateAgent` hat für diesen Wert
**keinen Parameter** (`UpdateClient.cs:330-342`). Unser Herunterfahren nimmt sich allein für
`host.StopAsync` bis zu 30 s, dazu Avalonia-Teardown und die Modul-Stopps. Reißt die Frist, tut der
Agent **nichts** — immerhin sicher, aber ohne Rückmeldung und ohne Stellschraube.
**Wunsch an DC:** `waitTimeoutSeconds` in `LaunchUpdateAgent` durchreichen.
**Bis dahin bei uns:** den Agenten selbst starten (eigener `ProcessStartInfo`) statt über
`LaunchUpdateAgent` — dann setzen wir `--wait-timeout` selbst.
### D-2 · Der Lizenzschlüssel wandert in die Kommandozeile · **hoch** · Wo: DC
Der Agent nimmt die Zugangsdaten der Release-Ablage ausschließlich als Argument entgegen:
`--license-key` bzw. `--download-user/--download-password`
(`Deploymentcenter.UpdateAgent/Program.cs:70-79`), und `UpdateClient.LaunchUpdateAgent` hängt sie
ebenso an (`UpdateClient.cs:366-369`). Umgebungsvariablen liest der Agent **an keiner Stelle**.
Auf einem Mehrbenutzersystem zeigt `ps` (bzw. der Task-Manager) die Argumente jedes laufenden
Prozesses. Das ist wörtlich dieselbe Begründung, mit der `UPGRADE.md §5` den `curl`-Aufruf im
Crontab verwirft — an dieser Stelle wird sie nicht angewandt. Für uns kommt hinzu: „keine Secrets
in argv" ist bei PolyTrader ein bereits **umgesetzter** Sicherheitsbefund; diese Integration würde
ihn zurücknehmen.
**Wunsch an DC:** Zugangsdaten zusätzlich über `DC_LICENSE_KEY` / `DC_DOWNLOAD_PASSWORD` oder über
stdin annehmen. Sauberer Workaround auf unserer Seite gibt es nicht.
### D-3 · Das Lizenzurteil ist nicht signiert — Rückschritt gegenüber LicenseLabrador · **mittelhoch** · Wo: DC
`LicenseClient` liest den Status aus reinem JSON (`LicenseClient.cs:191-237`). Der mitgeschickte
`nonce` (`:113`) wird in der Antwort **nie geprüft**, eine Signatur gibt es nicht. LicenseLabrador
verifizierte die Serverantwort per Ed25519 gegen einen einkompilierten Public Key und reflektierte
die Nonce — deshalb konnte niemand ohne den privaten Schlüssel ein „valid" erfinden.
Jetzt trägt allein TLS. Wer DNS oder die `hosts`-Datei umbiegt und ein eigenes Zertifikat in den
Trust Store legt — also der Besitzer der Maschine — kann sich ein `{"status":"valid"}` selbst
ausstellen. Für unser erklärtes Bedrohungsmodell („Schutz gegen einfaches Kopieren auf ein fremdes
System") reicht das; sobald Dritte die Software bekommen, ist es die schwächste Stelle der Kette.
Bemerkenswert: Für **Release-Pakete** führt `UPDATESERVICE_INTEGRATION_GUIDE §6` eine ausführliche
und richtige Begründung, warum dort asymmetrisch signiert werden muss („ein Update wird auf dem
Zielsystem geprüft") — beim Lizenzurteil gilt dasselbe Argument und fehlt die Umsetzung.
Zusatz: Der Client erzwingt **kein** `https://` (`serverBaseUrl` wird ungeprüft übernommen,
`LicenseClient.cs:118`). Das prüfen wir wie beim Heartbeat selbst.
### D-4 · `ILicensePrompt` ist eine tote Naht · **mittel** · Wo: DC (Doku) + unsere Analyse
`LicenseClient` nimmt ein `ILicensePrompt` entgegen und legt es in `_prompt` ab
(`LicenseClient.cs:62,69-72`) — **benutzt wird es nirgends**, im ganzen SDK gibt es keine weitere
Fundstelle. Unsere `ANALYSE-Linux-Portierung.md` (§5.2 und Risiko R6) baut die headless-Story
darauf auf, dass diese Abstraktion den Start auf einem Server ohne Display trägt. Sie tut nichts.
Kein Beinbruch — die Ablauflogik gehört ohnehin zu uns (`LicenseGate` hat sie bereits) —, aber die
Analyse muss korrigiert werden, und die Abstraktion bauen wir selbst.
### D-5 · Reihenfolgefalle beim Zugangsschutz trifft uns in der scharfen Variante · **mittel** · Wo: wir
`UPGRADE.md §16.1` beschreibt für bestehende Produkte „erst ausliefern, dann scharfschalten". Für
**neue** Produkte gilt das ausdrücklich nicht: `/releases/polytrader/` entsteht erst mit dem ersten
Upload und wird beim nächsten `cli/tick.php`-Lauf geschützt. Es gibt also kein Zeitfenster, in dem
sich ein noch ungeschütztes Paket ziehen ließe.
**Der allererste ausgelieferte Build muss die Zugangsdaten bereits mitbringen.** Veröffentlichen wir
zuerst ein Release, dessen Anwendung `credentials` noch nicht übergibt, fällt die Tür hinter ihm zu
und jede Erstinstallation bekommt 401.
Zweitens: Der Schutz beruht auf `.htaccess` und trägt nur unter Apache mit `AllowOverride`. Die
Anzeige „GESCHÜTZT" beweist nichts — nur der **Selbsttest** tut es. Gehört in unsere Abnahme, nicht
in die Zuversicht.
### D-6 · Der Packager würde unsere Secrets mitveröffentlichen · **hoch** · Wo: wir
`pack-and-deploy` verpackt das Publish-Verzeichnis. Bei uns liegt dort:
| Datei | Inhalt | Wie sie dorthin kommt |
|---|---|---|
| `appsettings.Local.json` | **MySQL-Verbindung mit Passwort** | `CopyToOutputDirectory` in `PolyTrader.App.Avalonia.csproj` |
| `master.key` | AES-Master-Key aller at-rest-Secrets | wird neben der Anwendung erwartet (`Program.cs:309`) |
| `openrouter.key`, `server_settings.xml`, `data.db`, `Logs/` | API-Schlüssel, Watchdog-Token, Lizenzschlüssel, Betriebsdaten | Betrieb |
Ohne strikte `excludePatterns` landet all das im Paket und damit auf dem Server. Der Zugangsschutz
macht es nicht harmlos: `UPDATESERVICE §5A/Grenzen` sagt es selbst — dann ist es eben statt
öffentlich „für jeden Lizenznehmer sichtbar". Der Packager warnt inzwischen bei
zugangsdatenverdächtigen Dateien, aber die Warnung ist **keine Sperre**.
Gegenmaßnahme: eigenes Publish-Profil in ein sauberes Verzeichnis, explizite `excludePatterns`,
und als Abnahmekriterium ein Blick in das fertige Archiv.
### D-7 · `BuildInfo.targets` kommt bei unserer Auslieferungsart nicht mit · **mittel** · Wo: DC + wir
`Deploymentcenter.Client.csproj` setzt weder `PackageId` noch `Version` und packt die
`Deploymentcenter.BuildInfo.targets` **nicht** ins NuGet-Paket (kein
`<None Pack="true" PackagePath="build/">`). Der Guide empfiehlt stattdessen einen relativen
`<Import>` ins Nachbar-Repo — genau die Kopplung, die wir bei LicenseLabrador bewusst aufgegeben
haben (Begründung in `lib/nuget/README.md`: PolyTrader muss ohne das Schwester-Repo bauen).
**Wunsch an DC:** `PackageId`/`Version` setzen und die Targets als `build/Deploymentcenter.Client.targets`
mitpacken — dann greift sie beim Paketverweis von selbst.
**Bis dahin bei uns:** die Datei einmalig nach `build/` kopieren, mit Herkunfts- und
Aktualisierungsvermerk.
### D-8 · Wir haben keine Versionsdisziplin · **mittel** · Wo: wir
In keiner `.csproj` steht ein `<Version>`, eine `Directory.Build.props` gibt es nicht. Damit trägt
jede Assembly `1.0.0.0`.
Der Packager prüft `--version` gegen die Hauptassembly und bricht bei Abweichung ab — das ist gut
so, denn der Fehler dahinter ist übel: Wird `1.0.1` als `1.0.2` veröffentlicht, aktualisieren alle
Installationen, melden weiterhin `1.0.1`, halten das Release erneut für neu und aktualisieren bei
**jedem Start** wieder. Ohne zentrale Version ist ein Release entweder unmöglich oder gefährlich.
Muss **vor** dem ersten Paket stehen.
### D-9 · `setup.json` kann unsere Konfiguration nicht schreiben · **mittel** · Wo: wir
Der Installer beherrscht die Formate `json` und `env` (`SETUP_INTEGRATION_GUIDE §3/§4`). Unsere
Betriebseinstellungen liegen in `server_settings.xml` — eigenes XML-Format, das er nicht kennt.
Konsequenz: Entweder verzichtet die Erstinstallation auf jede Vorbelegung, oder der
Deploymentcenter-Block (BaseUrl, Token, Kanal, Source, Lizenzschlüssel) zieht nach
`appsettings.json`. Zweiteres ist ohnehin sauberer — die Anwendung liest bereits
`appsettings.Local.json` über die `IConfiguration`, und nur so kann die Erstinstallation das
Anwendungstoken per `source: "provision"` selbst ziehen, ohne dass jemand ein Token abtippt.
### D-10 · Die Erstinstallation kann den Master-Key nicht lösen · **mittel** · Wo: wir
Der Installer schreibt ausdrücklich **Klartext** und verschlüsselt nichts
(`SETUP_INTEGRATION_GUIDE §3/Dateirechte`). Einen `POLYTRADER_MASTER_KEY` kann er nicht erzeugen.
Eine frisch installierte Instanz hätte also alle Secrets unverschlüsselt liegen, bis jemand von
Hand nachhilft — auf einem Zielsystem im Ausland der wahrscheinlichste Ort für „später".
Optionen: (a) Master-Key als `type: "secret"`-Feld abfragen, (b) PolyTrader erzeugt beim ersten
Start selbst einen und legt ihn mit `0600` ab, (c) über die systemd-Unit als Umgebungsvariable.
**Empfehlung: (b) + (c)**, (a) wäre eine weitere Stelle, an der jemand ein Geheimnis abtippt.
Positiv: Die im Guide geforderte Weiche „Entschlüsselung reicht Klartext unverändert durch" haben
wir bereits (`SecretProtection.Unprotect`). Der Guide warnt zu Recht, sie nicht eines Tages als
tote Altlast zu entfernen — sie gehört als **aktiv genutzt** dokumentiert.
### D-11 · Pfad-Inkonsistenz, die erst beim Installieren auffällt · **klein** · Wo: wir
`ServerSettings.Load("server_settings.xml")` ist **relativ zum Arbeitsverzeichnis**, `master.key`
wird dagegen aus `AppContext.BaseDirectory` gelesen (`Program.cs:309`). Solange man aus dem
Projektordner startet, fällt das nicht auf. Nach einer Installation nach `/opt/polytrader` mit
einer systemd-Unit, deren `WorkingDirectory` woanders zeigt, liest die Anwendung eine andere Datei,
als der Installer geschrieben hat. Vor D-6/L2 klären.
### D-12 · Der bestehende Heartbeat passt nicht — mehr als „Header und URL" · **klein** · Wo: wir
`WatchdogHeartbeatService` sendet an `{base}/api/heartbeat` mit `X-Watchdog-Key`
(`WatchdogHeartbeatService.cs:280,428`). Das Deploymentcenter erwartet
`{base}/api/watchdog/v1/ping` mit `Authorization: Bearer` (alternativ `X-Agent-Token`), Events
gehen nach `/api/watchdog/v1/event`.
`ANALYSE-Linux-Portierung.md §5.2` nennt das „nur Header und Ziel-URL". Tatsächlich kommen dazu:
`version` (sonst lässt sich ein Ausfall keinem Rollout zuordnen), `os`, `group`, das ganze
`checks`-Konzept, `metrics` — und `status: "stopped"`, das die Fehlalarm-Vermeidung beim geordneten
Beenden übernimmt. Unser `metadata`-Feld liest der Server dagegen nicht; es entfällt.
### D-13 · Error-Reporting braucht einen Handler, den es bei uns nicht gibt · **mittel** · Wo: wir
Weder `AppDomain.CurrentDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException`
kommen im Repo vor. Beim Bau lauern zwei Fallen:
1. **Rückkopplung.** Der Reporter meldet einen Fehlschlag über `TerminalLogger.Error` — was den
nächsten Report auslöst. Der Reporter darf seine eigenen Fehler niemals über den
eskalierenden Pfad melden. (DC drosselt bei 60 Reports/Minute/IP; darauf verlassen wir uns nicht.)
2. **Datenabfluss.** Unsere Fehlermeldungen enthalten Wallet-Adressen, Marktkennungen und Beträge,
der DC-Server steht beim Hoster. Das vorhandene `Sanitize()` des Heartbeats muss auch auf
Meldung **und Stacktrace** des Error-Reports laufen.
### D-14 · Kein Update ohne gültige Lizenz — auch nicht das reparierende · **klein, bewusst** · Wo: Entscheidung
Der Zugangsschutz koppelt Updates an eine gültige Lizenz. Eine Installation mit abgelaufener Lizenz
kann sich also nicht mehr selbst reparieren. Für eigene Systeme belanglos; sobald Kunden im Spiel
sind, ist es eine Entscheidung, keine Nebenwirkung.
### D-16 · `environment` im Fehler-Report ist heimlich ein Auth-Gate · **kleinmittel, überraschend** · Wo: DC (Doku + Fehlermeldung)
Beim Live-Test von D-3 lieferte `/api/errors/v1/report` mit einem funktionierenden,
`bugtracker:report`-berechtigten Token beharrlich `401 unauthorized: "Erwartet wird ein Token mit
dem Recht \"bugtracker:report\""` — obwohl der Token genau dieses Recht trug (per
`/api/tokens/v1/provision` selbst bestätigt).
Ursache: `report.php` liest `environment` aus dem **Request-Body** und reicht es an
`ApiAuth::requireScope($db, 'bugtracker:report', $environment, false)` durch;
`TokenManager::validateToken` lehnt danach ab, wenn `token['environment'] !== 'all' &&
token['environment'] !== $environment` (`TokenManager.php:317`). Unser Token war bei der
Provisionierung auf `environment: "development"` festgelegt, der Report schickte
`"environment": "production"` — Mismatch, Ablehnung.
Zwei Dinge daran sind problematisch:
1. **Kein Dokument erwähnt diese Kopplung.** `bugtracker.md §3` führt `environment` als reine
Metadatum-Angabe für `/report` (Bugtracker), nicht als Zugriffs-Gate. Für `/api/errors/v1/report`
steht es nirgends. Andere Endpunkte (`watchdog:ping`, `watchdog:read/evaluate`,
`license:deactivate`) übergeben `$environment` gar nicht erst an `requireScope` — die Kopplung
gilt also inkonsistent nur für diesen einen Endpunkt.
2. **Die Fehlermeldung nennt die falsche Ursache.** `401` mit `"Token mit dem Recht
bugtracker:report"` legt einen fehlenden Scope nahe. Der tatsächliche Grund (Environment-
Mismatch) taucht in der Antwort nirgends auf — von der Client-Seite aus ist das ohne Blick in den
Serverquelltext nicht diagnostizierbar.
**Für uns gelöst:** Token mit `environment: "all"` neu provisioniert (`dc_sub_109c1a03…`), gilt für
jeden gemeldeten Umgebungswert. **Wunsch an DC:** entweder den wahren Grund in der Fehlerantwort
nennen (z.B. eigener Code `environment_mismatch`), oder die Kopplung ganz aus `/errors/v1/report`
entfernen und `environment` wie bei den anderen Endpunkten rein deskriptiv behandeln.
### D-15 · `Unauthorized` wird nur im statischen Zweig erkannt · **klein** · Wo: DC
`CheckForUpdateAsync` setzt `result.Unauthorized` nur, wenn die statische `latest.json` mit 401
antwortet (`UpdateClient.cs:105-111`). Fehlt die `latest.json`, fällt die Prüfung auf die
**offene** API zurück — eine tote Lizenz sieht dann aus wie „kein Update vorhanden". Für die
Fehlersuche im Feld relevant.
---
## 2. Leitentscheidungen
1. **Das SDK kommt als lokales NuGet-Paket** in `lib/nuget`, wie schon `LicenseLabrador.Client` —
keine Projektreferenz ins Nachbar-Repo. Begründung unverändert: PolyTrader muss ohne das
Schwester-Repo bauen (Build-Server, Zielland-System).
2. **Der Heartbeat bleibt unsere eigene Implementierung.** Der Service ist erprobt (Live-Reload,
Entprellung, TLS-Zwang, No-Throw-Vertrag, 21 Tests); umgestellt werden Endpunkt, Auth und
Payload — nicht die Mechanik.
3. **Kein `Environment.Exit`, nirgends.** Weder bei ungültiger Lizenz (Entscheidung vom 31.07.2026)
noch beim Update (D-1). Ein Update läuft über denselben geordneten Weg wie das Beenden über die
Menüleiste, inklusive Bestätigungsdialog.
4. **Updates werden nie automatisch installiert.** Die Anwendung prüft beim Start, meldet im
Launcher und installiert erst auf ausdrückliche Bestätigung. Auch ein `is_critical`-Release
erzwingt nichts — offene Positionen schlagen jedes Sicherheitsargument.
5. **Revidiert bei der Umsetzung (14.08.2026):** Ursprünglich geplant war, dass der gesamte
Deploymentcenter-Block nach `appsettings.json` zieht. Für Watchdog-Felder (`WatchdogUrl`,
`WatchdogToken`, `WatchdogSource`, `WatchdogInstance`, `WatchdogIntervalSeconds`) blieb es bei
`server_settings.xml`/`ServerSettings` — die bestehende UI (maskierte Eingabe, Live-Reload) deckt
den Bedarf bereits ab, ein zweites Konfigurationssystem dafür wäre unnötig gewesen. Nur Werte
**ohne** bestehende UI-Heimat (ProjectSlug, Channel, Feature-Schalter für D-3/D-4) liegen in
`appsettings.json`/`DeploymentcenterOptions`; Secrets darin (Token, LicenseKey für D-2) in der
gitignorierten `appsettings.Local.json`, zusätzlich über `SecretProtection` verschlüsselbar wo
die bestehende Ablage (`ServerSettings.LicenseKey`) das schon leistet.
6. **Ausgeliefert wird ausschließlich `PolyTrader.App.Avalonia`**, für `win-x64` und `linux-x64`
(Entscheidung Richard, 14.08.2026: WinForms verschwindet demnächst vollständig, L5).
**Folge für diesen Plan:** Lizenzdialog und Startgate werden **nur noch in Avalonia** gebaut,
nicht in beiden Anwendungen. `Licensing/LicenseGate.cs` und `Ui/LicenseDialog.cs` im
WinForms-Projekt werden nicht portiert, sondern ersetzt und gelöscht. Das spart eine komplette
zweite Verdrahtung — und die WinForms-Fassung wäre ohnehin die einzige mit Lizenzprüfung
gewesen, während die tatsächlich benutzte Anwendung ungeprüft läuft.
7. **Watchdog und LicenseLabrador werden vollständig ausgebaut** (Entscheidung Richard,
14.08.2026). Kein Parallelbetrieb, keine Rückfallebene: Nach D-1/D-2 gibt es genau einen
Gegenüber, das Deploymentcenter. Siehe Schnitt **D-6**.
8. **Ein Anwendungstoken je Installation**, per `source: "provision"` beim Setup gezogen und auf
`watchdog:ping` + `bugtracker:report` beschränkt. Ein Master-Token wird **nie** ausgeliefert.
9. **Produkt-Slug ist `polytrader`** (Entscheidung Richard, 14.08.2026). Das alte `pt` aus dem
LicenseLabrador-Admin entfällt mit diesem Umzug ersatzlos.
10. **Der Master-Key wird bei der Erstinstallation erzeugt** (Entscheidung Richard, 14.08.2026),
nicht abgefragt — Einzelheiten in **D-5**.
---
## 3. Umsetzung in Schnitten
### D-0 · Fundament (ohne Serverkontakt) ✅ ERLEDIGT (14.08.2026)
Umgesetzt wie geplant: `Directory.Build.props` mit `<Version>0.1.0</Version>` für alle Projekte,
`Deploymentcenter.Client` 2.5.0 als lokales NuGet-Paket in `lib/nuget` (PackageReference in
`PolyTrader.Core.csproj`, `BuildInfo.targets` kommt jetzt automatisch über `build/` aus dem Paket
D-7 war zu diesem Zeitpunkt bereits vom Deploymentcenter-Team behoben, keine manuelle Übernahme
nötig), `nuget.config`-Mapping um `Deploymentcenter.*` ergänzt, `DeploymentcenterOptions` +
`Deploymentcenter`-Sektion in `appsettings.json` (Secrets in `appsettings.Local.json`, gitignored).
`PolyTrader.Core.BuildInfo.Version` erzeugt sich korrekt bei jedem Build. LicenseLabrador-Paket
bewusst noch nicht entfernt (das ist D-6, erst nach Live-Abnahme).
### D-0-Rest · Zwischenverifikation der Befunde (14.08.2026)
Vor dem Start Richards Hinweis geprüft, dass die Befunde behoben sein sollten: **5 von 6 bestätigt
behoben** (Commit `1967b49`, Deploymentcenter-Team) D-1 (Exit/`--wait-timeout`), D-2 (argv-Key,
jetzt `DC_LICENSE_KEY`-Umgebungsvariable), D-4 (`ILicensePrompt` jetzt über `EnsureLicensedAsync()`
genutzt), D-6 (Packager bricht bei Secrets ab statt nur zu warnen), D-7 (Paket jetzt `IsPackable`,
Version 2.5.0). Als Bonus zusätzlich behoben: D-15 (`Unauthorized` jetzt auch im API-Rückfallzweig
erkannt). **D-3 (unsigniertes Lizenzurteil) bleibt bestätigt offen** vom Deploymentcenter-Team
selbst so benannt („bewusst nicht Teil dieses Commits"), deckt sich mit unserer Einordnung in §6.
### D-1 · Watchdog auf die Deploymentcenter-API umstellen ✅ ERLEDIGT (14.08.2026)
Umgesetzt in [WatchdogHeartbeatService.cs](../../src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs).
Abweichung von der ursprünglichen Planung: **kein neues Konfigurationsschema.** `ServerSettings`
(XML, Live-Reload, maskierte Token-Eingabe bereits erprobte Mechanik) bleibt die Quelle für
`WatchdogUrl`/`WatchdogToken`/`WatchdogSource`/`WatchdogInstance`/`WatchdogIntervalSeconds`; nur
ihre Bedeutung ändert sich (zeigen jetzt auf das Deploymentcenter statt auf watchdog.mhdf.de).
`DeploymentcenterOptions`/`appsettings.json` wäre hier ein zweites, unnötiges Konfigurationssystem
gewesen die bestehende UI/Masking/Live-Reload-Maschinerie deckt genau denselben Bedarf ab.
`DeploymentcenterOptions` bleibt für D-2/D-3/D-4 reserviert (Werte ohne bestehende UI-Heimat:
ProjectSlug, Channel, Feature-Schalter).
- Endpunkte `…/api/watchdog/v1/ping` und `…/api/watchdog/v1/event`, Header `Authorization: Bearer`
statt `X-Watchdog-Key`. `TryBuildEndpoint` nimmt jetzt einen relativen Pfad statt eines
Aktionsnamens (`PingPath`/`EventPath`-Konstanten).
- Payload ergänzt: `version` (aus `PolyTrader.Core.BuildInfo.Version`, nicht mehr aus der
Assembly-Version stimmt jetzt mit `Directory.Build.props` überein), `os`
(`RuntimeInformation.OSDescription`). Das bisherige `metadata`-Feld entfällt (der Server liest es
nicht, siehe Befund D-12).
- **`checks`-Mechanik neu:** `RegisterCheck(name, Func<(bool ok, string? message)>)` /
`UnregisterCheck(name)` Composition Root registriert die konkreten Prüfungen, der Dienst selbst
kennt ihre Bedeutung nicht. Eine werfende Prüfung meldet sich selbst als fehlgeschlagen statt den
Heartbeat zu verhindern. In `Program.cs` (Avalonia) verdrahtet: `vpn`-Prüfung gegen
`MullvadVpnService.IsConnected`. **`db`/`market_feed`-Prüfungen sind bewusst noch nicht verdrahtet**
die jeweiligen Dienste geben aktuell kein Gesundheitssignal nach außen; das ist ein Fast-Follow,
kein offener Blocker.
- Beim Herunterfahren sendet `StopAsync` jetzt **zwei** Dinge (vorher nur das Event): einen
Heartbeat mit `status:"stopped"` (das ist, was der Evaluator tatsächlich auswertet) **und**
weiterhin das `stopped_graceful`-Event ins Ereignisprotokoll.
- Tests: 8 neue/geänderte Fälle (Endpunkte, Bearer-Header, `checks`-Payload inkl. Fehlerfall),
gesamt 454 Tests grün.
**Live-Abnahme (14.08.2026, gegen den echten Server, Dev-Sub-Token):**
`POST /api/watchdog/v1/ping` mit dem neuen Payload → `state: "up"`, `app_version` korrekt übernommen.
`POST /api/watchdog/v1/event` (`stopped_graceful`) → angenommen. Erneuter Ping mit
`status:"stopped"` → Monitor wechselt zu `state: "stopped"`, `state_changed: true`. Test-Monitor
`polytrader-dev-probe` bleibt im Deploymentcenter stehen (Zustand `stopped`, alarmiert nicht).
### D-2 · Lizenz auf das Deploymentcenter umstellen ✅ ERLEDIGT (15.08.2026)
Umgesetzt in [Licensing/LicenseGate.cs](../../src/PolyTrader.App.Avalonia/Licensing/LicenseGate.cs),
[Licensing/LicenseCli.cs](../../src/PolyTrader.App.Avalonia/Licensing/LicenseCli.cs) und den neuen
Lizenz-Schaltflächen in [SettingsWindow.axaml(.cs)](../../src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml).
Zwei Abweichungen von der ursprünglichen Planung, beide vereinfachend:
- **Kein eigenes `ILicensePrompt`, kein eigener modaler Lizenzdialog.** Für die GUI wird die
bereits vorhandene, erprobte Settings-Oberfläche wiederverwendet — dieselbe maskierte
Prompt/Save-Mechanik wie beim Watchdog-Token (`btnLicenseKey` = „Lizenzschlüssel setzen …",
speichert verschlüsselbar in `server_settings.xml`) plus ein neuer `btnCheckLicense` = „Lizenz
jetzt prüfen", der den gespeicherten Schlüssel gegen den Server validiert und Status + Hardware-ID
in einem Info-Dialog anzeigt. Ein zusätzliches, eigenes Dialogfenster nur für die Lizenz wäre eine
zweite, redundante Eingabe-Oberfläche gewesen. Für `--headless`/CLI genügt die vom SDK
mitgelieferte `ConsoleLicensePrompt` (wird hier gar nicht gebraucht, siehe nächster Punkt) —
eine eigene Fassung hätte keinen Mehrwert geboten.
Grund für den Verzicht auf einen echten Avalonia-Dialog **vor** dem Hauptfenster: Avalonias
Startreihenfolge (`AppBuilder.Setup()` darf pro Prozess nur einmal laufen) macht einen zweiten,
vorgeschalteten Dialog vor `StartWithClassicDesktopLifetime` unnötig fragil; ein im laufenden
Fenster erreichbarer Button ist die robustere Lösung und deckt denselben Bedarf.
- **Modulentscheidung läuft still, ohne Dialog, vor dem Host-Aufbau.** `LicenseGate.CheckUsable`
validiert den in `server_settings.xml` gespeicherten Schlüssel synchron (wie einst die
WinForms-Fassung) und entscheidet, welche Module überhaupt registriert werden **nicht** über
`EnsureLicensedAsync`/`ILicensePrompt` des SDK (die interaktive Korrektur läuft über die
Settings-UI, s.o., nicht über eine erneute Abfrage an dieser Stelle). Eine per Dialog neu
eingetragene Lizenz wirkt für Module deshalb **erst beim nächsten Start** bewusst, konsistent
mit der Entscheidung vom 31.07.2026 (keine Laufzeit-Revalidierung).
- `DeploymentcenterOptions` wird jetzt **vor** dem Host-Aufbau eigenständig aus
`appsettings.json`/`appsettings.Local.json` geladen (`Program.LoadDeploymentcenterOptions`), weil
die Modulliste schon vor dem eigentlichen `IHost` feststehen muss. Dieselbe Instanz wird
anschließend auch reguär in die DI registriert.
- **`--smoke-ui` bewusst ausgenommen:** `enforceLicense: !smoke` — die Konstruktionsprüfung soll auf
einem (nie lizenzierten) Build-Server weiterhin ALLE Modul-Fenster testen, nicht nur die
Core-Shell. Verifiziert: `--smoke-ui` konstruiert weiterhin alle 4 Modul-Fenster plus das
Einstellungsfenster mit den neuen Lizenz-Feldern (7 statt 6 Settings-Abschnitte).
- TLS wird — anders als im SDK (Befund D-3) — im Gate selbst erzwungen, exakt wie beim Watchdog.
- CLI-Schalter `--license-status`, `--license-set-key <schlüssel>`, `--license-deactivate` nach der
Konvention aus LICENSE_INTEGRATION_GUIDE §4. `--license-deactivate` verlangt `DC_SHARED_KEY` aus
der Umgebung (Administrationsrechner) und verweigert sich mit klarer Meldung ohne ihn — der
Shared Key gehört nicht in die ausgelieferte Anwendung.
- **Keine eigenen Unit-Tests angelegt** (wie schon bei der WinForms-Fassung dokumentiert
begründet): `PolyTrader.Tests` referenziert `PolyTrader.App.Avalonia` nicht, der Gate ist dünner
Leim über dem SDK. Abdeckung über `--smoke-ui` (Konstruktion) und die Live-Verifikation unten.
**Live-Verifikation (15.08.2026, echter Server, Dev-Lizenz mit 9999 Aktivierungen von Richard):**
```
> PolyTrader.App.Avalonia.exe --license-set-key LLAB2-12345-67890-ABCDE-FGHIJ
valid: License is valid
Lizenz gültig und in server_settings.xml gespeichert (Klartext).
> PolyTrader.App.Avalonia.exe --license-status
HWID v2: 2:win:a765bd47c2458fd518a7a1ad8a9d1d4b654c91085dca4667f33e7cad4427bf93 (machine-guid)
Produkt: polytrader
Server: https://dc.mhdf.de
Cache: valid
Offline noch bis: 2026-08-18 07:56:54Z
Gespeicherter Schlüssel (server_settings.xml): gesetzt (Klartext)
```
Damit ist derselbe Codepfad geprüft, den `LicenseGate.CheckUsable` beim Start benutzt
(`LicenseClient.ValidateAsync`) nicht nur ein isolierter Testaufruf. Die volle GUI/`--headless`
wurde **bewusst nicht** gestartet, um keine echten Handelsverbindungen (Polymarket-WSS,
Alchemy-Listener) aus dieser Sitzung heraus zu öffnen; das bleibt Richards manuelle Abnahme.
`max_activations` war bereits großzügig gesetzt (Richards Hinweis), keine Admin-Änderung nötig.
### D-3 · Fehler-Reporting ✅ ERLEDIGT (17.08.2026)
Umgesetzt in [DeploymentcenterErrorReporter.cs](../../src/PolyTrader.Core/Services/DeploymentcenterErrorReporter.cs)
und [SecretRedactor.cs](../../src/PolyTrader.Core/Security/SecretRedactor.cs) (die
Secret-Muster-Prüfung aus `WatchdogHeartbeatService.Sanitize` wurde dorthin ausgelagert, damit
Heartbeat-Meldung — kurz, einzeilig — und Stacktrace — lang, mehrzeilig — dieselbe Musterprüfung,
aber unterschiedliche Kürzungsregeln bekommen, ohne Code zu verdoppeln).
- Drei Quellen wie geplant: `AppDomain.UnhandledException` (`level: "fatal"`, synchron mit 3-s-Frist
gesendet, da der Prozess unmittelbar danach stirbt), `TaskScheduler.UnobservedTaskException`
(`"warning"`, `SetObserved()` verhindert einen Prozessabbruch durch die schon behandelte Ausnahme),
`TerminalLogger`-Fehler (`"error"`, Fire-and-forget wie beim Heartbeat).
- **No-Throw + keine Rückkopplung** wie gefordert: eigene Sendefehler laufen ausschließlich über
`TerminalLogger.Warning` (niemals `.Error` sonst würde der Reporter sich selbst erneut auslösen,
da er genau auf `.Error` hört).
- Eigene Ratenbegrenzung: 20 Meldungen/Minute (gleitendes Fenster, kein Hintergrund-Timer), deutlich
unter dem Server-Limit von 60/Minute/IP Absicht ist, lokal zu drosseln, bevor eine
Fehlerschleife den Server oder andere Dienste auf derselben IP überhaupt erreicht.
- `Sanitize` (Nachricht, ≤200 Zeichen, einzeilig) und die neue `SanitizeStackTrace` (≤4000 Zeichen,
**mehrzeilig bleibt erhalten** ein einzeiliger Stacktrace wäre zum Debuggen wertlos) laufen
beide vor jedem Versand.
- **Korrektur gegenüber der Planung:** kein `client_ref`/Idempotenz-Schlüssel implementiert anders
als beim Bugtracker-`/report` dedupliziert `/api/errors/v1/report` bereits serverseitig über einen
inhaltsbasierten Gruppenschlüssel (bestätigt im Live-Test unten, `occurrence_count` stieg ohne
jedes Zutun). Ein zusätzlicher `client_ref` hätte hier keinen Zweck gehabt.
- Registrierung als `BackgroundService` wie `WatchdogHeartbeatService` (rein ereignisgesteuert,
`ExecuteAsync` wartet nur auf Abbruch) konsistent mit dem etablierten Muster, obwohl kein
eigener Takt gebraucht wird.
- Konfiguration über `DeploymentcenterOptions.Token`/`.ErrorReportingEnabled` (aus
`appsettings.Local.json`) anders als beim Watchdog gibt es hierfür keine bestehende
UI-Heimat in `ServerSettings`, passt also zur in D-1 begründeten Aufteilung.
- 14 neue Tests (Payload, Auth-Header, Secrets aus Nachricht UND Stacktrace, Ratenbegrenzung,
alle drei Quellen inkl. `internal` gemachter Handler-Methoden für direkte Testbarkeit ohne echte
Prozessabstürze zu provozieren). Gesamt 468 Tests grün.
**Live-Verifikation (17.08.2026, echter Server):** Zwei identische Meldungen über
`/api/errors/v1/report` mit dem exakten Payload-Format des neuen Codes → erste Meldung
`is_new: true, occurrence_count: 1`, zweite `is_new: false, occurrence_count: 2` serverseitige
Deduplizierung bestätigt.
**Nebenbefund beim Live-Test: D-16** (neu, siehe §1) das `environment`-Feld im Report-Body ist
ein undokumentiertes Auth-Gate gegen die Provisionierung des Tokens; die Fehlermeldung bei einem
Mismatch nennt fälschlich einen fehlenden Scope. Für uns gelöst durch Neuprovisionierung mit
`environment: "all"` (Token in `appsettings.Local.json` aktualisiert, betrifft auch den
Watchdog-Token-Kommentar dort).
---
### D-0 · Fundament (ohne Serverkontakt) — ursprüngliche Planung
- `Directory.Build.props` mit **einer** `<Version>` für alle Projekte (D-8).
- `Deploymentcenter.BuildInfo.targets` nach `build/` übernehmen und einbinden; `BuildInfo.Version`
ersetzt das heutige `Assembly.GetName().Version` im Heartbeat (D-7).
- SDK paketieren: `dotnet pack Deploymentcenter.Client -c Release -o lib/nuget`; `nuget.config` um
das Quellen-Mapping `Deploymentcenter.*` → `local` ergänzen; `LicenseLabrador.Client` samt
`.nupkg` entfernen.
- Neuer Konfigurationsabschnitt `Deploymentcenter` in `appsettings.json` + `DeploymentcenterOptions`
(BaseUrl, ProjectSlug, Channel, Token, LicenseKey, Watchdog-Source/-Instance/-Intervall,
Schalter für Heartbeat / Fehlermeldungen / Update-Prüfung).
**Abnahme:** Solution baut, Tests grün, `BuildInfo.Version` stimmt mit `<Version>` überein,
`lib/nuget` enthält nur noch das DC-Paket.
### D-1 · Watchdog auf die Deploymentcenter-API umstellen — ursprüngliche Planung
- Endpunkte `…/api/watchdog/v1/ping` und `…/api/watchdog/v1/event`, Header
`Authorization: Bearer`. TLS-Zwang und Loopback-Ausnahme bleiben.
- Payload ergänzen: `version` (aus `BuildInfo`), `os`, `group`.
- `checks` füllen — das ist der eigentliche Gewinn gegenüber heute: `db` (MySQL erreichbar),
`market_feed` (Alter des letzten Ticks), `vpn` (Mullvad-Zustand), `modules` (laufen die aktiven
Module). Ein `ok`-Heartbeat mit fehlschlagender Prüfung wird serverseitig auf `warning` gestuft.
- `metrics` schlank halten (Jobs in der Warteschlange, offene Orders als **Anzahl**). Weiterhin
**keine** Kontostände, Wallet-Adressen oder Trader-Namen.
- Beim Herunterfahren `status: "stopped"` senden (zusätzlich zum bestehenden
`stopped_graceful`-Event) — das ist der vom Evaluator ausgewertete Weg.
- Konfiguration aus `DeploymentcenterOptions`; die alten `Watchdog*`-Felder aus `ServerSettings`
entfernen (mit einmaliger Übernahme beim Start, damit niemand neu tippt).
- `WatchdogHeartbeatTests` nachziehen.
**Abnahme:** Monitor erscheint auf `dc.mhdf.de` mit korrekter `version`; Beenden über die
Menüleiste → Monitor `stopped`, **kein** Alarm; Prozess abschießen → nach 4× Intervall genau **ein**
Down-Alarm. Voraussetzung, die vorher zu prüfen ist: `cli/tick.php` läuft dort tatsächlich als
Cron — ohne ihn sind alle Monitorzustände wertlos (`/api/health` → `checks.evaluator.ok`).
### D-2 · Lizenz auf das Deploymentcenter umstellen — ursprüngliche Planung
- `LicenseGate` auf `Deploymentcenter.Client.LicenseClient` umbauen:
`ValidateAsync(slug, key, baseUrl, appVersion)`, Ergebnis `LicenseValidationResult`.
`LicenseClient.DefaultAppVersion = BuildInfo.Version` einmal beim Start.
- Statusabbildung: `IsValid` → voller Start; **`IsTransient` → weiterlaufen und nur warnen**
(Netzstörung ist kein Urteil); alles andere → eingeschränkter Modus ohne Module.
`https://` selbst erzwingen (D-3).
- Eigene Prompt-Abstraktion bauen (D-4): Avalonia-Dialog für den Desktop, Konsolenvariante für
`--headless`. Der Dialog zeigt Hardware-ID (kopierbar), Status und Offline-Restlaufzeit.
- **Gate und Dialog entstehen im Avalonia-Projekt** — dort läuft heute gar keine Prüfung, und die
WinForms-Fassung wird nicht mitgezogen (Leitentscheidung 6). `Licensing/LicenseGate.cs` und
`Ui/LicenseDialog.cs` werden mit D-6 gelöscht, nicht portiert.
- CLI-Schalter `--license-status`, `--license-set-key`, `--license-deactivate` nach der Konvention
des Guides.
**Abnahme:** gültige Lizenz → alle Module; Netz getrennt → `valid_offline` mit sichtbarer
Restlaufzeit; Schlüssel entfernt → nur Core-Shell, kein Absturz; `--headless` ohne Display
blockiert nicht.
**Vor dem ersten echten Start im DC einstellen:** Produkt `polytrader` mit großzügigem
`max_activations` (ein Windows-Neuinstall erzeugt eine neue MachineGuid = neuer Platz) und
`default_cache_ttl_hours` als Offline-Kulanz für eigene Maschinen.
> **Ein Aktivierungsplatz wird beim ersten echten Start verbraucht.** Die Prüfung deshalb nicht
> „mal eben" von einer Entwicklermaschine aus ausprobieren, sondern dort, wo die Installation
> hingehört — so wie beim letzten Mal gehandhabt.
### D-3 · Fehler-Reporting — ursprüngliche Planung
- `DeploymentcenterErrorReporter` in `PolyTrader.Core`: `POST /api/errors/v1/report`, Scope
`bugtracker:report`.
- Quellen: `AppDomain.UnhandledException` (`level: "fatal"`),
`TaskScheduler.UnobservedTaskException` (`warning`), `TerminalLogger`-Fehler (`error`).
- Pflichtmerkmale: No-Throw, keine Rückkopplung (D-13), eigene Ratenbegrenzung,
`Sanitize()` auf Meldung **und** Stacktrace, abschaltbar über die Konfiguration.
- `client_ref` je Prozessstart+Fehlergruppe setzen, damit ein Neuversuch kein Duplikat anlegt.
- Serverseitig Ignore-Regeln für bekanntes Rauschen anlegen (Polymarket-429, VPN-Reconnect) —
jeweils **mit** Alarmschwelle, denn der Zähler ist der eigentliche Zweck.
**Abnahme:** erzwungener Testfehler erscheint als Gruppe; zweites Auftreten erhöht
`occurrence_count`, statt ein zweites Item anzulegen; bei nicht erreichbarem DC läuft PolyTrader
unverändert weiter und erzeugt **keine** Fehlerschleife.
### D-4 · Auslieferung (UpdateService) ✅ VOLLSTÄNDIG ERLEDIGT (20.08.2026)
**Umgesetzt (Schritte 1 und 3 der ursprünglichen Planung):**
- Update-Prüfung beim Start in [LauncherWindow.axaml(.cs)](../../src/PolyTrader.App.Avalonia/Views/LauncherWindow.axaml):
fire-and-forget, nur aktiv bei `Deploymentcenter:UpdateCheckEnabled` (Vorgabe `false`), nutzt
`ReleaseCredentials.FromLicenseKey(...)` mit dem entschlüsselten `ServerSettings.LicenseKey`.
Ein gefundenes Update erscheint als Banner (Version, Changelog, `is_critical`-Hinweis) mit
„Jetzt installieren …" und „Später". **Installiert nie automatisch** — deckt sich mit
Leitentscheidung 4. `result.Unauthorized` wird erkannt und bewusst still behandelt (die
Lizenzprüfung aus D-2 hat eine tote Lizenz bereits gemeldet, ein zweiter Hinweis wäre Rauschen).
- Installation nach Bestätigungsdialog über `UpdateClient.LaunchUpdateAgent(..., exitCurrentApp:
false, waitForCurrentProcess: true, waitTimeoutSeconds: 40, licenseKey: ...)`, danach
`_uiHost.RequestShutdown()` — **derselbe** geordnete Weg wie „Beenden" über die Menüleiste,
inklusive der 10-Sekunden-Sicherheitsabfrage. Kein Sonderpfad, der die Bestätigung umgeht.
- `deploy/packager.config.example.json` (Schritt 1) mit den `excludePatterns`/`preservePatterns`
aus der Planung, plus Nachtrag `*.pfx/*.key/*.pem/*.p12` (der Packager selbst prüft seit
Version 2.5.0 zusätzlich Dateiname UND Inhalt, siehe D-6 der Befunde — diese Liste ist die
erste, nicht die einzige Verteidigungslinie). `deploy/packager.config.json` (echte FTP-Zugangsdaten
+ `updateservice:publish`-Token) neu in `.gitignore`.
- **Live geprüft (HTTP-Vertrag, per curl):**
`GET /api/updateservice/v1/check?product=polytrader&version=0.1.0&channel=prod` →
`update_available: false, latest_version: "0.1.0"` — bestätigt, dass für `polytrader` aktuell
**kein** Release auf dem Server liegt (die `2.0.1`-Zeile in `sql/schema.sql` ist Beispiel-Seed-Daten
aus dem Deploymentcenter-Repo, nicht live). Der Code-Pfad selbst nutzt dieselbe SDK-Methode, die
bereits in D-1/D-2/D-3 erprobt ist.
**Nachgezogen (20.08.2026, auf Richards ausdrücklichen Wunsch — „lass uns einen Win und Linux
Release erstellen und hochladen"):**
- Signierschlüssel serverseitig bestätigt: `GET /api/updateservice/v1/pubkey` liefert einen
RSA-Key (Fingerprint `ed99a38d…`) — die zuvor offene Checkliste-Voraussetzung war in Wahrheit
schon erfüllt.
- `dotnet publish -c Release --self-contained true` für `win-x64` (124 MB) und `linux-x64`
(120 MB) in saubere `/tmp`-Verzeichnisse, **nicht** das Debug-Output. Self-contained bewusst
gewählt, weil der Zweck genau der Erstinstallations-Test auf einem frischen System ist, das
nicht zwingend eine .NET-Laufzeit mitbringt.
- **Fund dabei:** `appsettings.Local.json` (mein echtes lokales MySQL-Passwort + Dev-Token) landete
im Publish-Output — die `CopyToOutputDirectory`-Regel in der `.csproj`, die genau für diesen
Fall in D-6 der Befunde und im eigenen `packager.config.example.json`-Kommentar dokumentiert ist.
Vor jedem weiteren Schritt von Hand entfernt, dann zusätzlich mit einem eigenen Grep nach
gefüllten `Password=`-Feldern, `dc_master_…`/`dc_sub_…`-Tokens und privaten Schlüsseln über beide
Verzeichnisse geprüft (leer). Der Packager selbst hätte es beim `--allow-secrets`-losen Lauf
ohnehin blockiert — das war die zweite, nicht die einzige Verteidigungslinie.
- **`deploy/packager.config.json`** (gitignored) angelegt. FTP-Zugangsdaten kamen auf Richards
Anweisung aus `Deploymentcenter/scripts/deploy_config.json` (dieselbe Hosting-Zugangsdaten, mit
denen auch das Deploymentcenter selbst deployt wird). **Remote-Pfad per read-only FTPS-Listing
verifiziert statt geraten:** `/releases/` liegt als eigenständiges Verzeichnis auf FTP-Root-Ebene
(nicht unter `/public_html/releases/`, wie das Beispiel im UPDATESERVICE-Guide nahelegt) —
bestätigt durch die dort bereits vorhandenen Verzeichnisse `clawddotnet/` und `predictalytics/`
echter Nachbarprojekte. `ftpRemoteBaseDir: "/releases"` entsprechend gesetzt.
- Ein `updateservice:publish`-Token separat provisioniert (`environment: "all"`, wie schon bei D-3
gelernt) — nicht das Dev-Token aus `appsettings.Local.json` wiederverwendet, das hat nur
`watchdog:ping`/`bugtracker:report`.
- **Beide Releases veröffentlicht** über `pack-and-deploy`: `polytrader 0.1.0 (prod, win-x64)` und
`polytrader 0.1.0 (prod, linux-x64)`. Versionsgegenprobe lief automatisch durch (Assembly-Version
stimmte mit `--version` überein). `appsettings.json` korrekt als „GESCHÜTZT" (preservePatterns)
markiert, `*.pdb` je Projekt korrekt ausgeschlossen.
**Live-Abnahme (20.08.2026):**
- [x] `GET /api/updateservice/v1/check?product=polytrader&version=0.0.0&channel=prod&platform=win-x64`
**und** `...&platform=linux-x64` → beide `update_available: true, latest_version: "0.1.0"`.
- [x] Beide Antworten enthalten ein gefülltes `manifest_signature`-Feld — signiert.
- [x] Zugangsschutz: **nicht sofort aktiv** — `curl -I .../package.tar.gz` lieferte direkt nach dem
Publish noch `200` statt `401`. Deckt sich mit der Dokumentation: für ein *neues* Produkt
entsteht `/releases/polytrader/` erst mit dem ersten Upload, der Schutz erst mit dem
nächsten `cli/tick.php`-Durchlauf. Per Hintergrund-Poll auf `401` gewartet statt einfach
anzunehmen, dass er greift — nach rund zwei Minuten aktiv geworden.
- [x] **Vollständige Selbsttest-Sequenz danach grün:** ohne Zugangsdaten → `401` (beide
Plattformen); mit dem Dev-Lizenzschlüssel als Basic-Auth (`lic_<sha256-präfix>:<schlüssel>`)
→ `200`; `.htpasswd` direkt abgerufen → `403`.
Reihenfolge ist hier bindend — siehe D-5.
**Ursprüngliche Planung (jetzt umgesetzt, s.o.):**
1. `packager.config.json` mit strikten `excludePatterns` (`appsettings.Local.json`, `master.key`,
`openrouter.key`, `server_settings.xml`, `*.db`, `Logs/**`, `*.pdb`) und `preservePatterns`
(`appsettings.json`).
2. Sauberes Publish-Verzeichnis je Plattform (`win-x64`, `linux-x64`), **nicht** das Debug-Output.
3. In der Anwendung: Update-Prüfung beim Start mit
`credentials: ReleaseCredentials.FromLicenseKey(...)`, Anzeige im Launcher, Installation nur auf
Bestätigung und über den geordneten Shutdown-Pfad (D-1).
4. **Erst danach** das erste Release veröffentlichen.
5. Zugangsschutz erzeugen und **Selbsttest** laufen lassen; zusätzlich von Hand:
`curl -I …/releases/polytrader/prod/win-x64/<version>/package.tar.gz` → 401.
**Abnahme:** Update auf einem Testsystem behält `appsettings.Local.json`, `server_settings.xml`,
`master.key` und `data.db`; eine im neuen Release entfallene DLL ist verschwunden; ein Abbruch
mitten im Anwenden hinterlässt eine lauffähige Installation (Rollback); und der Blick ins Archiv:
```bash
tar -tzf package.tar.gz | grep -Ei 'local|master\.key|openrouter|settings\.xml|\.db$'
```
Diese Ausgabe muss **leer** sein.
### D-5 · Erstinstallation (`setup.json`) ✅ Code-seitig erledigt (17.08.2026), Live-Abnahme offen
**Umgesetzt:**
- [setup.json](../../src/PolyTrader.App.Avalonia/setup.json) im App-Projekt (per `CopyToOutputDirectory`
im Publish-Paket) mit zwei Zielen: `appsettings.json` (Deploymentcenter-Block: `BaseUrl` via
`detect:baseurl`, `ProjectSlug` via `detect:project`, `Channel`, `Token` via `provision` mit den
Scopes `watchdog:ping`+`bugtracker:report`) und `appsettings.Local.json` (`Database:MySqlConnectionString`
als `secret`).
**Korrektur gegenüber der ursprünglichen Planung:** kein `Watchdog:Source`-Feld — die
Watchdog-Konfiguration liegt seit D-1 bewusst in `server_settings.xml` (maskierte UI,
Live-Reload), und `setup.json` kann nur `json`/`env`-Ziele schreiben, kein XML. Aktivierung von
Watchdog **und** Eintragen des Lizenzschlüssels bleiben deshalb bewusste Post-Install-Schritte
über das Einstellungsfenster — beide sind seit D-1/D-2 genau dafür gebaut (maskierte
Eingabe-Buttons, sofort wirksam ohne Neustart). Im `setup.json`-Kommentar dokumentiert, damit das
nicht als vergessenes Feld missverstanden wird.
- **Master-Key-Erzeugung (D-10)** in [MasterKeyResolver.cs](../../src/PolyTrader.Core/Security/MasterKeyResolver.cs)
(neu, testbar in `PolyTrader.Core` statt direkt in `Program.cs` verdrahtet) + gemeinsamer
[FilePermissions.cs](../../src/PolyTrader.Core/Security/FilePermissions.cs)-Helfer (ersetzt die
bisher in `SettingsWindow.axaml.cs` duplizierte `RestrictToOwner`-Logik). Reihenfolge:
Umgebungsvariable → vorhandene Datei → **nur wenn beides fehlt** neu erzeugen. 8 Tests, u.a.
explizit „ein zweiter/dritter Aufruf erzeugt niemals neu" und „ein fehlgeschlagener Schreibversuch
wirft nie, sondern warnt". Live geprüft: frisches Verzeichnis ohne `master.key` →
`--smoke-ui`-Lauf erzeugt die Datei mit der erwarteten Log-Meldung, `SecretProtection` aktiviert
sich sofort danach in derselben Sitzung.
- [polytrader.service](../../deploy/polytrader.service) (systemd-Unit-Vorlage) löst **D-11**: setzt
`WorkingDirectory` ausdrücklich auf das Installationsverzeichnis, mit Begründung im Kommentar
(sonst laden `server_settings.xml`, relativ zum Arbeitsverzeichnis, und `master.key`, relativ zu
`AppContext.BaseDirectory`, aus unterschiedlichen Orten). `SIGTERM` mit 45 s Frist für den
geordneten Shutdown-Pfad statt `SIGKILL`.
**Bewusst nicht gemacht:**
- **Installationskonto im DC anlegen** — das Anlegen eines Kontos mit Zugangsdaten ist eine
Handlung, die grundsätzlich beim Menschen bleibt, nicht beim Agenten. Richard legt es im WebUI
unter *System → 👤 Benutzer* an (Rolle `installer`, siehe SETUP_INTEGRATION_GUIDE §2).
- **Volle Live-Abnahme** (`update-agent --action install` auf einem echten System) — braucht ein
veröffentlichtes Release (D-4, noch offen, siehe dort) und das Installationskonto von oben. Ohne
beides lässt sich nur die App-seitige Hälfte prüfen, was hiermit geschehen ist.
Ursprünglich geplant, jetzt überholt:
- **Master-Key beim ersten Start selbst erzeugen** (Entscheidung Richard, 14.08.2026):
`ConfigureSecretProtection` legt, wenn weder `POLYTRADER_MASTER_KEY` noch `master.key` vorliegen,
einen kryptografisch zufälligen Schlüssel an und schreibt ihn mit `File.SetUnixFileMode(…,
UserRead | UserWrite)` — sonst entsteht er unter Linux mit `644`, also world-readable.
Drei Punkte, die dabei nicht untergehen dürfen:
- **Nur erzeugen, wenn wirklich keiner da ist.** Ein versehentliches Überschreiben macht jedes
bereits verschlüsselte Secret in der Datenbank unlesbar — das ist der einzige wirklich
unumkehrbare Fehler in diesem ganzen Plan. Erzeugung deshalb ausschließlich beim Nichtvorhandensein
der Datei, mit deutlichem Log-Eintrag und einem Hinweis, den Schlüssel zu sichern.
- **Ort ist `AppContext.BaseDirectory`** — dieselbe Stelle, an der er heute gelesen wird. Zusammen
mit D-11 klären, dass die Anwendung nach der Installation dieselbe Datei findet.
- **`master.key` gehört in `excludePatterns`** (D-6) und darf nie in ein Paket geraten.
- Installationskonto im DC anlegen (Rolle `installer`, **kein** Admin).
- systemd-Unit schreiben — der Installer legt bewusst keinen Dienst an. Dabei D-11 auflösen
(`WorkingDirectory` = Installationsverzeichnis oder absolute Pfade).
**Abnahme:** `update-agent --action install` auf einem frischen Linux-System führt zu einer
lauffähigen Installation, ohne dass eine Datei von Hand bearbeitet wird; `master.key` entsteht mit
`600`; ein zweiter Start erzeugt **keinen** neuen Schlüssel.
### D-6 · Watchdog und LicenseLabrador ausbauen
Entscheidung Richard (14.08.2026): kein Parallelbetrieb. Läuft **nach** D-1 und D-2, damit zu
keinem Zeitpunkt beides fehlt.
- `LicenseLabrador.Client` aus `PolyTrader.App.csproj` und `lib/nuget` entfernen, Quellen-Mapping
in `nuget.config` von `LicenseLabrador.*` auf `Deploymentcenter.*` umstellen.
- `Licensing/LicenseGate.cs`, `Ui/LicenseDialog.cs` (+ `.Designer.cs`) löschen — ersetzt durch die
Avalonia-Fassung aus D-2.
- Alte `Watchdog*`-Felder aus `ServerSettings` und der Settings-Oberfläche entfernen (der
Token-Button und die Statusanzeige wandern zum Deploymentcenter-Block).
- Beide Umsetzungspläne als **abgelöst** kennzeichnen, `ANALYSE-Linux-Portierung.md` §5.2 und die
Risikozeile R6 korrigieren (D-4 der Befunde: `ILicensePrompt` trägt die headless-Story nicht).
- Serverseitig: `watchdog.mhdf.de` und `license.mhdf.de` abschalten, sobald die Live-Abnahme steht.
Damit erledigen sich die beiden offenen Auflagen aus dem Altplan (Secrets rotieren, UTC/`NOW()`)
von selbst — **nicht** vorher abschalten, sonst fehlt die Rückfallebene während der Abnahme.
**Abnahme:** Die Solution baut ohne LicenseLabrador-Paket, `grep -ri licenselabrador` findet im
Code nur noch historische Erwähnungen in Plandokumenten, und `server_settings.xml` enthält keine
Watchdog-Felder mehr.
> **Reihenfolge:** Die alten Dienste erst abschalten, wenn D-1 und D-2 **live** abgenommen sind —
> nicht, wenn sie gebaut sind.
---
## 4. Reihenfolge, Abhängigkeiten, Aufwand
| Schritt | Inhalt | hängt an | Aufwand | Stand |
|---|---|---|---|---|
| D-0 | Fundament: Version, SDK-Paket, Optionen | — | 0,5 PT | ✅ 14.08.2026 |
| D-1 | Watchdog umstellen | D-0 | 0,51 PT | ✅ 14.08.2026, live verifiziert |
| D-2 | Lizenz umstellen + Prompt-Naht (nur Avalonia) | D-0 | 1 PT | ✅ 15.08.2026, live verifiziert |
| D-3 | Fehler-Reporting | D-0 | 0,51 PT | ✅ 17.08.2026, live verifiziert |
| D-4 | Auslieferung | D-0, D-2 (Schlüssel für den Paketabruf) | 12 PT | ✅ 20.08.2026, `polytrader 0.1.0` live für win-x64+linux-x64, signiert, live geprüft |
| D-5 | Erstinstallation + Master-Key-Erzeugung | D-4 | 0,51 PT | Code ✅ 17.08.2026; Release liegt jetzt vor — Live-Install fehlt nur noch das Installationskonto (Richard) |
| D-6 | Watchdog/LicenseLabrador ausbauen | Live-Abnahme von D-1 und D-2 | 0,5 PT | offen — beide Vorbedingungen live ✅, wartet auf Richards manuelle GUI-Abnahme |
**Summe 47 PT.** D-1, D-2 und D-3 sind untereinander unabhängig und können in beliebiger
Reihenfolge laufen. Zwei Reihenfolgen sind dagegen bindend:
- **D-2 vor D-4** — ohne Lizenzschlüssel im Update-Weg sperrt uns der Zugangsschutz mit dem ersten
Release aus (Befund D-5).
- **D-6 nach der Live-Abnahme**, nicht nach dem Bau.
### Serverseitige Voraussetzungen (vor D-1)
- [x] Projekt `polytrader` im Deploymentcenter angelegt (Slug festgelegt, id 2, aus dem Seed-Bestand)
- [x] `cli/tick.php` läuft als Cron — `/api/health` meldet `checks.evaluator.ok: true` (14.08.2026 geprüft)
- [x] Master-Token vorhanden; Dev-Sub-Token für `polytrader` provisioniert (Scopes `watchdog:ping`,
`bugtracker:report`) — für den Produktivbetrieb ein eigenes Anwendungstoken je Installation
nachziehen, siehe Leitentscheidung 8
- [ ] Release-Signierschlüssel hinterlegt (`/api/updateservice/v1/pubkey` antwortet) — vor D-4
- [x] `max_activations` für `polytrader` großzügig gesetzt (Richard, vor 15.08.2026: Dev-Lizenz mit
9999 Aktivierungen) — `default_cache_ttl_hours` ungeprüft übernommen (Live-Test zeigte
plausible ~72h Offline-Kulanz), bei Bedarf im Admin nachjustieren
- [ ] Installationskonto (Rolle `installer`) angelegt — vor D-5
---
## 5. Entscheidungen (Richard, 14.08.2026)
| Frage | Entscheidung | Wirkt auf |
|---|---|---|
| Produkt-Slug | **`polytrader`**, `pt` entfällt | D-2, D-4, Serverseite |
| Zielarchitektur | **Alles auf das Deploymentcenter.** Watchdog und LicenseLabrador verschwinden, kein Parallelbetrieb | D-6 |
| Oberfläche | **Nur noch Avalonia**, WinForms wird demnächst vollständig ausgebaut | Leitentscheidung 6, D-2, D-4 |
| Master-Key | **Bei der Erstinstallation erzeugen** | D-5, D-10 |
## 6. Weiterhin offen
**Stand 17.08.2026:** Von den ursprünglich zwei DC-seitigen Wünschen ist einer erledigt
(Zugangsdaten aus argv → `DC_LICENSE_KEY`-Umgebungsvariable, `waitTimeoutSeconds` einstellbar,
`BuildInfo.targets` jetzt im Paket — alles Commit `1967b49`, siehe §0). **Offen bleibt nur D-3 der
Befunde** (unsigniertes Lizenzurteil) — vom Deploymentcenter-Team selbst bestätigt und bewusst
zurückgestellt. Trägt für den Eigenbetrieb, wird relevant, sobald Dritte die Software bekommen.
Kein Blocker für diesen Plan.
Dazu neu **D-16** (§1): das `environment`-Feld in `/api/errors/v1/report` ist ein undokumentiertes
Auth-Gate mit irreführender Fehlermeldung — für uns durch Neuprovisionierung gelöst (Token mit
`environment: "all"`), als Wunsch an DC offen (entweder die echte Ursache in der Fehlerantwort
nennen, oder die Kopplung entfernen).
### Entscheidung nötig: erstes Release veröffentlichen (D-4)
D-4 ist bis auf die eigentliche Auslieferung fertig — Update-Prüfung, Installations-Bestätigung und
der geordnete Neustart-Weg sind gebaut und laufen über denselben Pfad wie „Beenden". **Nicht
durchgeführt:** ein Paket schnüren und veröffentlichen. Drei Dinge sprechen dafür, das nicht
nebenbei zu tun, sondern mit Richard abzustimmen:
1. Ein veröffentlichtes Release ist auf einem **von mehreren Projekten geteilten** Server
persistent, nicht so folgenlos rückgängig zu machen wie ein lokaler Build.
2. Der Zugangsschutz (§5A des UpdateService-Guides) verändert `.htaccess`/`.htpasswd` in
`/releases/` — einem Verzeichnisbaum, den auch andere Produkte nutzen.
3. Der **Release-Signierschlüssel ist serverseitig noch nicht bestätigt** (offener Punkt in der
Checkliste §4). Ein erstes, unsigniertes Release wäre vermeidbar.
**Sobald Richard grünes Licht gibt:** `deploy/packager.config.json` aus der Vorlage anlegen (echtes
`updateservice:publish`-Token, FTP-Zugangsdaten), `dotnet publish` für `win-x64` und `linux-x64` in
saubere Verzeichnisse (nicht das Debug-Output), `pack-and-deploy` je Plattform aufrufen, danach
Zugangsschutz + Selbsttest prüfen (§4B der Planung).
Binary file not shown.
+338 -84
View File
@@ -3,71 +3,112 @@
x:Class="PolyTrader.App.Avalonia.App"> x:Class="PolyTrader.App.Avalonia.App">
<!-- <!--
Farbschema. RequestedThemeVariant wird NICHT hier festgelegt, sondern beim Start vom Farbschema und Designsprache. RequestedThemeVariant wird NICHT hier festgelegt, sondern beim
ThemeManager aus den Server-Settings gesetzt (hell / dunkel / dem System folgen). 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 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 DynamicResource gebunden dadurch wechseln sie beim Umschalten von selbst. Nur wenige
Stellen setzen Farben im Code (Grid-Zeilen, Umschalter); die holen sie über Stellen setzen Farben im Code (Grid-Zeilen, Umschalter); die holen sie über
ThemeManager.Brush(...) und zeichnen sich bei ThemeChanged neu. 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> <Application.Resources>
<ResourceDictionary> <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.ThemeDictionaries>
<ResourceDictionary x:Key="Light"> <ResourceDictionary x:Key="Light">
<!-- Flächen und Rahmen --> <!-- Grundflächen und Rahmen -->
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#FAFAFA" /> <SolidColorBrush x:Key="AppBgBrush" Color="#F2F2F3" />
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#F3F3F3" /> <SolidColorBrush x:Key="AppSurfaceBrush" Color="#E9E9EA" />
<SolidColorBrush x:Key="AppCardBrush" Color="#F5F5F5" /> <SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#EDEDEE" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#DDDDDD" /> <SolidColorBrush x:Key="AppCardBrush" Color="#E9E9EA" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#D0D0D1" />
<SolidColorBrush x:Key="AppDividerBrush" Color="#D0D0D1" />
<!-- Texte --> <!-- Texte -->
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#777777" /> <SolidColorBrush x:Key="AppTextBrush" Color="#1D1F20" />
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#666666" /> <SolidColorBrush x:Key="AppMutedTextBrush" Color="#76787A" />
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#444444" /> <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 --> <!-- Bedeutungsfarben -->
<SolidColorBrush x:Key="AppPositiveBrush" Color="#2E7D32" /> <SolidColorBrush x:Key="AppPositiveBrush" Color="#2F6B46" />
<SolidColorBrush x:Key="AppNegativeBrush" Color="#C62828" /> <SolidColorBrush x:Key="AppNegativeBrush" Color="#8E3229" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#996600" /> <SolidColorBrush x:Key="AppWarningBrush" Color="#8A6420" />
<!-- Zeilenfärbung der Trade-Listen (Pastell auf hellem Grund) --> <!-- Benachrichtigungsleiste (Kante links + Symbol) -->
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#F5C8C8" /> <SolidColorBrush x:Key="AppNotifyWarnBrush" Color="#B5822A" />
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#D4F0D4" /> <SolidColorBrush x:Key="AppNotifyErrorBrush" Color="#C0392B" />
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#8CD68C" /> <!-- 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 --> <!-- Trading-Umschalter im Launcher -->
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#90EE90" /> <SolidColorBrush x:Key="AppToggleActiveBrush" Color="#A8CFAF" />
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#FFA500" /> <SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#E0B26A" />
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#CD5C5C" /> <SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#D89A92" />
<!-- Supervisor-Analyseverlauf --> <!-- Supervisor-Analyseverlauf -->
<SolidColorBrush x:Key="AppChatUserBrush" Color="#4682B4" /> <SolidColorBrush x:Key="AppChatUserBrush" Color="#5980A6" />
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#2E8B57" /> <SolidColorBrush x:Key="AppChatAgentBrush" Color="#4E8168" />
</ResourceDictionary> </ResourceDictionary>
<ResourceDictionary x:Key="Dark"> <ResourceDictionary x:Key="Dark">
<!-- Flächen und Rahmen --> <!-- Grundflächen und Rahmen -->
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#2B2B2B" /> <SolidColorBrush x:Key="AppBgBrush" Color="#1A1C1E" />
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#333333" /> <SolidColorBrush x:Key="AppSurfaceBrush" Color="#232527" />
<SolidColorBrush x:Key="AppCardBrush" Color="#2F2F2F" /> <SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#1F2123" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#484848" /> <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 --> <!-- Texte: heller, aber nicht reinweiß sonst flimmert es auf Dunkel -->
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#9A9A9A" /> <SolidColorBrush x:Key="AppTextBrush" Color="#EEF0F1" />
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#AAAAAA" /> <SolidColorBrush x:Key="AppMutedTextBrush" Color="#8D9092" />
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#CFCFCF" /> <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 --> <!-- Bedeutungsfarben: gleiche Aussage, aufgehellt für Kontrast auf Dunkel -->
<SolidColorBrush x:Key="AppPositiveBrush" Color="#81C784" /> <SolidColorBrush x:Key="AppPositiveBrush" Color="#6FAE86" />
<SolidColorBrush x:Key="AppNegativeBrush" Color="#EF5350" /> <SolidColorBrush x:Key="AppNegativeBrush" Color="#D9756A" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#FFB74D" /> <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 <!-- Zeilenfärbung: gedämpft statt pastell die hellen Töne würden auf Dunkel
blenden und den Text unlesbar machen. Reihenfolge der Aussage bleibt. --> blenden und den Text unlesbar machen. Reihenfolge der Aussage bleibt. -->
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#4E2A2A" /> <SolidColorBrush x:Key="AppTradeLossBrush" Color="#4A2B27" />
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#2C3F2C" /> <SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#2A3A30" />
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#3D6B3D" /> <SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#3A5C46" />
<!-- Trading-Umschalter --> <!-- Trading-Umschalter -->
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#3F7A3F" /> <SolidColorBrush x:Key="AppToggleActiveBrush" Color="#3F7A55" />
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#A76B1F" /> <SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#9A6C25" />
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#8E3B3B" /> <SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#8E3B3B" />
<!-- Supervisor-Analyseverlauf --> <!-- Supervisor-Analyseverlauf -->
<SolidColorBrush x:Key="AppChatUserBrush" Color="#7FB3D8" /> <SolidColorBrush x:Key="AppChatUserBrush" Color="#7EA3C6" />
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#68B68A" /> <SolidColorBrush x:Key="AppChatAgentBrush" Color="#78B49A" />
</ResourceDictionary> </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries> </ResourceDictionary.ThemeDictionaries>
@@ -77,62 +118,275 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<!-- Projektweite Grundgestaltung. Bewusst hier zentral statt je Fenster wiederholt, <!-- ═══ Grundgestaltung ═══════════════════════════════════════════════════════
damit alle Fenster gleich aussehen (wie zuvor die einheitliche Segoe-UI-Vorgabe). --> Projektweit zentral statt je Fenster wiederholt, damit alle Ansichten gleich
<Style Selector="DataGrid"> aussehen. Die Designsprache des Mockups lebt fast vollständig hier. -->
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HeadersVisibility" Value="Column" /> <Style Selector="Window">
<Setter Property="IsReadOnly" Value="True" /> <Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
<Setter Property="CanUserResizeColumns" Value="True" /> <Setter Property="FontSize" Value="13" />
<Setter Property="CanUserSortColumns" Value="True" /> <Setter Property="Background" Value="{DynamicResource AppBgBrush}" />
<Setter Property="RowHeight" Value="24" /> <Setter Property="Foreground" Value="{DynamicResource AppTextBrush}" />
<Setter Property="FontSize" Value="12" />
</Style> </Style>
<!-- KPI-Kachel: entspricht den zweizeiligen Labels der WinForms-Oberfläche <!-- Überschriften laufen in der schmalen Schnittvariante (Barlow Condensed). -->
("<Titel> \n <Wert>"), siehe docs/UI-SPEZIFIKATION-WinForms.md. --> <Style Selector="TextBlock.h4">
<Style Selector="Border.kpi"> <Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="Background" Value="{DynamicResource AppCardBrush}" /> <Setter Property="FontSize" Value="19" />
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" /> <Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="Margin" Value="0,0,8,8" />
<Setter Property="MinWidth" Value="150" />
</Style> </Style>
<Style Selector="Border.kpi > StackPanel > TextBlock.caption"> <Style Selector="TextBlock.h5">
<Setter Property="FontSize" Value="11" /> <Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
<Setter Property="Foreground" Value="{DynamicResource AppCaptionTextBrush}" /> <Setter Property="FontSize" Value="15" />
</Style>
<Style Selector="Border.kpi > StackPanel > TextBlock.value">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<!-- Werkzeugleiste oben in jedem Fenster (Ersatz für ToolStrip). --> <!-- „Kicker": die kleine Versalzeile in Akzentfarbe über einer Überschrift.
<Style Selector="Border.toolbar"> Avalonia kennt kein text-transform die Texte stehen im XAML in Versalien. -->
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" /> <Style Selector="TextBlock.kicker">
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" /> <Setter Property="FontSize" Value="10" />
<Setter Property="BorderThickness" Value="0,0,0,1" /> <Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Padding" Value="6,4" /> <Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
</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" />
</Style> </Style>
<!-- Gedämpfte Hinweistexte (Statuszeilen, Erklärungen unter Feldern). --> <!-- Gedämpfte Hinweistexte (Statuszeilen, Erklärungen unter Feldern). -->
<Style Selector="TextBlock.muted"> <Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
</Style> </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.Styles>
</Application> </Application>
+8 -4
View File
@@ -42,12 +42,16 @@ namespace PolyTrader.App.Avalonia
ModuleViews.Register(uiHost, Services); ModuleViews.Register(uiHost, Services);
ViewIcons.AssignDefaults(uiHost); ViewIcons.AssignDefaults(uiHost);
var launcher = new LauncherWindow(uiHost, Services); // Einfenster-Shell: ein Fenster, die Views hängen als Seiten darin.
uiHost.SetMainWindow(launcher); var shell = new ShellWindow(uiHost, Services);
desktop.MainWindow = launcher; 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; desktop.ShutdownMode = global::Avalonia.Controls.ShutdownMode.OnMainWindowClose;
// Erste Seite: die Startseite (frühere LauncherForm).
uiHost.ActivateMain();
} }
base.OnFrameworkInitializationCompleted(); 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> <ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" /> <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.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> </ItemGroup>
</Project> </Project>
+94 -16
View File
@@ -41,7 +41,18 @@ namespace PolyTrader.App.Avalonia
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase)); bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", 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, // Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen // WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
@@ -82,11 +93,35 @@ namespace PolyTrader.App.Avalonia
.WithInterFont() .WithInterFont()
.LogToTrace(); .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> /// <summary>
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die /// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
/// aktiven Module. Kennt die Oberfläche nicht. /// aktiven Module. Kennt die Oberfläche nicht.
/// </summary> /// </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 serverSettings = ServerSettings.Load(ServerSettingsPath);
var logger = new TerminalLogger(); var logger = new TerminalLogger();
@@ -115,6 +150,16 @@ namespace PolyTrader.App.Avalonia
new AccountingModule() new AccountingModule()
}; };
var disabled = new HashSet<string>(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase); 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(); var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
return Host.CreateDefaultBuilder() return Host.CreateDefaultBuilder()
@@ -123,6 +168,7 @@ namespace PolyTrader.App.Avalonia
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false)) config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) => .ConfigureServices((context, services) =>
{ {
services.AddSingleton(dcOptions);
var databaseOptions = new DatabaseOptions var databaseOptions = new DatabaseOptions
{ {
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
@@ -147,7 +193,22 @@ namespace PolyTrader.App.Avalonia
services.AddSingleton<INotificationSink>( services.AddSingleton<INotificationSink>(
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>())); 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 // MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge). // Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
@@ -159,9 +220,15 @@ namespace PolyTrader.App.Avalonia
module.RegisterServices(services, context.Configuration); 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<MarketSyncService>();
services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>()); services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>());
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>()); services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos. // Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton<AvaloniaUiHost>(); services.AddSingleton<AvaloniaUiHost>();
@@ -189,7 +256,7 @@ namespace PolyTrader.App.Avalonia
ViewIcons.AssignDefaults(uiHost); ViewIcons.AssignDefaults(uiHost);
int failures = 0; int failures = 0;
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ==="); Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ===");
foreach (var view in uiHost.Views) foreach (var view in uiHost.Views)
{ {
try 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 try
{ {
_ = new Views.LauncherWindow(uiHost, host.Services); _ = new Views.ShellWindow(uiHost, host.Services);
Console.WriteLine("[OK] LauncherWindow konstruiert"); Console.WriteLine("[OK] ShellWindow konstruiert");
} }
catch (Exception ex) catch (Exception ex)
{ {
failures++; failures++;
Console.WriteLine($"[FEHLER] LauncherWindow: {ex.GetType().Name}: {ex.Message}"); Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}");
} }
try try
@@ -255,9 +324,12 @@ namespace PolyTrader.App.Avalonia
// Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau. // Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau.
string[] themeKeys = string[] themeKeys =
{ {
"AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush", "AppBorderBrush", "AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush",
"AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush", "AppBorderBrush", "AppDividerBrush",
"AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
"AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush",
"AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush", "AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush",
"AppNotifyWarnBrush", "AppNotifyErrorBrush",
"AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush", "AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush",
"AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush", "AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush",
"AppChatUserBrush", "AppChatAgentBrush" "AppChatUserBrush", "AppChatAgentBrush"
@@ -298,17 +370,23 @@ namespace PolyTrader.App.Avalonia
}; };
/// <summary> /// <summary>
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>) und aktiviert /// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>, sonst wird
/// die at-rest-Verschlüsselung. Muss VOR jeder Credential-Entschlüsselung laufen. /// einer erzeugt D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder
/// Credential-Entschlüsselung laufen.
/// </summary> /// </summary>
private static void ConfigureSecretProtection(TerminalLogger logger) 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"); string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim(); 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); SecretProtection.Configure(masterKey);
if (SecretProtection.IsConfigured) if (SecretProtection.IsConfigured)
@@ -7,19 +7,29 @@ using PolyTrader.Core.Modularity;
namespace PolyTrader.App.Avalonia.Shell namespace PolyTrader.App.Avalonia.Shell
{ {
/// <summary> /// <summary>
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>. Verhält sich wie der frühere /// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>.
/// WinForms-<c>ShellUiHost</c>: je View höchstens ein Fenster, ein bereits offenes wird nach vorn ///
/// geholt, alle Fenster öffnen maximiert. /// <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 /// <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 /// <see cref="object"/>) hier wird auf <see cref="Control"/> gecastet. Ein falscher Typ ist ein
/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster erzeugen.</para> /// Programmierfehler und soll laut scheitern, nicht still eine leere Seite erzeugen.</para>
/// </summary> /// </summary>
public sealed class AvaloniaUiHost : IModuleUiHost 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 List<ModuleView> _views = new();
private readonly Dictionary<string, Window> _open = new(); private readonly List<UnavailableView> _unavailable = new();
private Window? _mainWindow; private readonly Dictionary<string, Control> _pages = new();
private Views.ShellWindow? _shell;
private string? _activeViewId;
private bool _shutdownDialogOpen; private bool _shutdownDialogOpen;
/// <summary>True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde.</summary> /// <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; 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); public void RegisterView(ModuleView view) => _views.Add(view);
/// <summary>Setzt das Hauptfenster (Launcher) Ziel für <see cref="ActivateMain"/>.</summary> /// <summary>
public void SetMainWindow(Window main) => _mainWindow = main; /// 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() /// <summary>Bekannte, aber nicht geladene Module (siehe <see cref="RegisterUnavailable"/>).</summary>
{ public IReadOnlyList<UnavailableView> UnavailableViews => _unavailable;
if (_mainWindow == null) return;
if (_mainWindow.WindowState == WindowState.Minimized) /// <summary>Setzt das Shell-Fenster Ziel aller Seitenwechsel und Besitzer der Dialoge.</summary>
_mainWindow.WindowState = WindowState.Maximized; public void SetShell(Views.ShellWindow shell) => _shell = shell;
_mainWindow.Activate();
} /// <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) public void OpenView(string viewId)
{ {
@@ -52,35 +85,20 @@ namespace PolyTrader.App.Avalonia.Shell
public void OpenView(ModuleView view) 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) page = (Control)view.CreateView();
existing.WindowState = WindowState.Normal; _pages[view.Id] = page;
existing.Activate();
return;
} }
var window = (Window)view.CreateView(); _activeViewId = view.Id;
if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title; _shell?.ShowPage(view.Title, page);
// 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();
OpenStateChanged?.Invoke(); OpenStateChanged?.Invoke();
} }
/// <summary> /// <summary>
/// Zeigt die Sicherheitsabfrage (Beenden-Schaltfläche 10 s gesperrt) und fährt bei Bestätigung /// 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> /// </summary>
public async void RequestShutdown() public async void RequestShutdown()
{ {
@@ -89,8 +107,8 @@ namespace PolyTrader.App.Avalonia.Shell
try try
{ {
var dlg = new Views.ShutdownConfirmWindow(); var dlg = new Views.ShutdownConfirmWindow();
bool confirmed = _mainWindow != null bool confirmed = _shell != null
? await dlg.ShowDialog<bool>(_mainWindow) ? await dlg.ShowDialog<bool>(_shell)
: await dlg.ShowDialog<bool>(new Window()); : await dlg.ShowDialog<bool>(new Window());
if (!confirmed) return; if (!confirmed) return;
+19 -3
View File
@@ -10,7 +10,7 @@ using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Shell namespace PolyTrader.App.Avalonia.Shell
{ {
/// <summary> /// <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 /// Terminal, Jobs). Gegenstück zur entsprechenden Registrierung im WinForms-Program.cs
/// die View-IDs sind bewusst identisch, damit Symbole und Reihenfolge unverändert greifen. /// die View-IDs sind bewusst identisch, damit Symbole und Reihenfolge unverändert greifen.
/// </summary> /// </summary>
@@ -21,10 +21,25 @@ namespace PolyTrader.App.Avalonia.Shell
public static void Register(IModuleUiHost host, IServiceProvider services) 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 host.RegisterView(new ModuleView
{ {
Id = "core.dashboard", 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", Group = "Core",
Order = 10, Order = 10,
CreateView = () => new Views.DashboardWindow( CreateView = () => new Views.DashboardWindow(
@@ -47,7 +62,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<TradingState>(), services.GetRequiredService<TradingState>(),
services.GetRequiredService<TerminalLogger>(), services.GetRequiredService<TerminalLogger>(),
services.GetService<WatchdogHeartbeatService>(), services.GetService<WatchdogHeartbeatService>(),
services.GetService<MullvadVpnService>()) services.GetService<MullvadVpnService>(),
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>())
}); });
host.RegisterView(new ModuleView host.RegisterView(new ModuleView
@@ -1,5 +1,6 @@
using System; using System;
using System.Linq; using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Modularity; 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 /// 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. /// 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 /// <para>Eine Seite bekommt nur, was auch geladen ist: ist ein Modul über
/// <c>DisabledModules</c> abgeschaltet, fehlen seine Dienste im Container dann entfällt auch /// <c>DisabledModules</c> abgeschaltet oder fehlt die Lizenz, fehlen seine Dienste im Container.
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para> /// 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> /// </summary>
public static class ModuleViews 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", Id = "copytrading.main",
Title = "Copytrading", Title = "Copytrading",
@@ -33,7 +36,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<Modules.CopyTrading.Persistence.ICopyTradeLogRepository>()) 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", Id = "resolutionfarming.main",
Title = "ResolutionFarming", Title = "ResolutionFarming",
@@ -48,7 +52,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfSettingsRepository>()) 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", Id = "supervisor.main",
Title = "Supervisor", Title = "Supervisor",
@@ -63,7 +68,8 @@ namespace PolyTrader.App.Avalonia.Shell
services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>()) 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", Id = "accounting.main",
Title = "Accounting", 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> /// <summary>
private static void RegisterIfLoaded(IServiceProvider services, string moduleName, Action register) /// 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>() bool loaded = services.GetServices<IPolyTraderModule>()
.Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)); .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> /// <summary>Symbol-Schlüssel → Dateiname in <c>Resources/</c> (als Avalonia-Asset eingebettet).</summary>
private static readonly Dictionary<string, string> FileByKey = new() private static readonly Dictionary<string, string> FileByKey = new()
{ {
["dashboard"] = "dashboard.png", ["start"] = "dashboard.png",
["dashboard"] = "token_quantifier.png",
["settings"] = "setting_tools.png", ["settings"] = "setting_tools.png",
["terminal"] = "error_log.png", ["terminal"] = "error_log.png",
["jobs"] = "system_time.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> /// <summary>Standard-Symbolschlüssel je View-ID identisch zur WinForms-Shell.</summary>
private static readonly Dictionary<string, string> DefaultKeyByViewId = new() private static readonly Dictionary<string, string> DefaultKeyByViewId = new()
{ {
[AvaloniaUiHost.StartViewId] = "start",
["core.dashboard"] = "dashboard", ["core.dashboard"] = "dashboard",
["core.settings"] = "settings", ["core.settings"] = "settings",
["core.terminal"] = "terminal", ["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" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels" xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia" xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow" x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow"
x:DataType="vm:DashboardChartsModel" x:DataType="vm:DashboardChartsModel">
Title="Dashboard"
Width="1714" Height="1083">
<!-- <!--
Modulübergreifendes Dashboard aus dem Core-Trade-Log. Modulübergreifendes Dashboard aus dem Core-Trade-Log.
@@ -18,8 +15,6 @@
damit interaktiv (Tooltips, Zoom) und ohne System.Drawing. damit interaktiv (Tooltips, Zoom) und ohne System.Drawing.
--> -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top"> <Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnRefresh" Content="Aktualisieren" /> <Button Name="btnRefresh" Content="Aktualisieren" />
@@ -168,4 +163,4 @@
</TabControl> </TabControl>
</DockPanel> </DockPanel>
</Window> </UserControl>
@@ -19,7 +19,7 @@ namespace PolyTrader.App.Avalonia.Views
/// die Diagramme jetzt aus <see cref="DashboardChartsModel"/> (LiveCharts2). /// die Diagramme jetzt aus <see cref="DashboardChartsModel"/> (LiveCharts2).
/// Layout vollständig in DashboardWindow.axaml. /// Layout vollständig in DashboardWindow.axaml.
/// </summary> /// </summary>
public partial class DashboardWindow : Window public partial class DashboardWindow : UserControl
{ {
private readonly DashboardChartsModel _charts = new(); private readonly DashboardChartsModel _charts = new();
private readonly ObservableCollection<DashboardTradeRow> _history = new(); private readonly ObservableCollection<DashboardTradeRow> _history = new();
@@ -43,8 +43,6 @@ namespace PolyTrader.App.Avalonia.Views
public DashboardWindow(IModuleUiHost host, ITradeLogRepository tradeLog, TradingState state, public DashboardWindow(IModuleUiHost host, ITradeLogRepository tradeLog, TradingState state,
IReadOnlyList<ModuleActivationInfo>? moduleInfos = null, string? settingsPath = null) : this() IReadOnlyList<ModuleActivationInfo>? moduleInfos = null, string? settingsPath = null) : this()
{ {
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.dashboard", this);
_tradeLog = tradeLog; _tradeLog = tradeLog;
_state = state; _state = state;
_moduleInfos = moduleInfos; _moduleInfos = moduleInfos;
@@ -75,9 +73,10 @@ namespace PolyTrader.App.Avalonia.Views
InitModulesTab(); InitModulesTab();
// Die KPI-Farbe wird im Code gesetzt und wechselt daher nicht von selbst mit dem Thema. // Die KPI-Farbe wird im Code gesetzt und wechselt daher nicht von selbst mit dem Thema.
void OnTheme() => ApplyScope(); // Kein Abmelden mehr: Seiten werden in der Einfenster-Shell einmal erzeugt und leben
Shell.ThemeManager.ThemeChanged += OnTheme; // bis zum Programmende (siehe AvaloniaUiHost) ein Gegenstück zu Window.Closed gibt es
Closed += (_, _) => Shell.ThemeManager.ThemeChanged -= OnTheme; // hier nicht, und Unloaded würde bei jedem Seitenwechsel feuern.
Shell.ThemeManager.ThemeChanged += ApplyScope;
} }
// ===== Daten laden / Scope ===== // ===== Daten laden / Scope =====
@@ -1,5 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
namespace PolyTrader.App.Avalonia.Views namespace PolyTrader.App.Avalonia.Views
@@ -7,6 +8,10 @@ namespace PolyTrader.App.Avalonia.Views
/// <summary> /// <summary>
/// Kleiner Allzweck-Dialog: Hinweis, Ja/Nein-Rückfrage und (maskierte) Eingabe. /// Kleiner Allzweck-Dialog: Hinweis, Ja/Nein-Rückfrage und (maskierte) Eingabe.
/// Ersetzt die MessageBox-/Prompt-Aufrufe der WinForms-Oberfläche. /// 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> /// </summary>
public partial class DialogWindow : Window public partial class DialogWindow : Window
{ {
@@ -35,31 +40,48 @@ namespace PolyTrader.App.Avalonia.Views
cancel.Click += (_, _) => Close(false); cancel.Click += (_, _) => Close(false);
} }
/// <summary>Reiner Hinweis mit OK.</summary> /// <summary>
public static async Task Info(Window owner, string title, string message) /// 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(); if (TopLevel.GetTopLevel(owner) is Window window) return window;
dlg.Setup(title, message, withInput: false, masked: false, okText: "OK", withCancel: false); return (global::Avalonia.Application.Current?.ApplicationLifetime
await dlg.ShowDialog(owner); as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
} }
/// <summary>Ja/Nein-Rückfrage. Liefert <c>true</c> bei Bestätigung.</summary> /// <summary>Reiner Hinweis mit OK.</summary>
public static async Task<bool> Confirm(Window owner, string title, string message, string okText = "Ja") 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(); var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: false, masked: false, okText: okText, withCancel: true); 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> /// <summary>
/// Eingabe. Liefert <c>null</c> bei Abbruch leerer String bedeutet bewusst „Wert entfernen" /// Eingabe. Liefert <c>null</c> bei Abbruch leerer String bedeutet bewusst „Wert entfernen"
/// (dieselbe Unterscheidung wie in der bisherigen Oberfläche). /// (dieselbe Unterscheidung wie in der bisherigen Oberfläche).
/// </summary> /// </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(); var dlg = new DialogWindow();
dlg.Setup(title, message, withInput: true, masked: masked, okText: "OK", withCancel: true); 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; return ok ? dlg.FindControl<TextBox>("txtInput")!.Text ?? string.Empty : null;
} }
} }
@@ -1,10 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:models="using:PolyTraderSharp.Models" xmlns:models="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.JobsWindow" x:Class="PolyTrader.App.Avalonia.Views.JobsWindow">
Title="Server Jobs"
Width="1429" Height="833">
<!-- <!--
Ansicht der Hintergrund-Jobs (JobManager). Ansicht der Hintergrund-Jobs (JobManager).
@@ -14,8 +11,6 @@
auf anderen Auflösungen mitwächst — die Proportionen bleiben erhalten. auf anderen Auflösungen mitwächst — die Proportionen bleiben erhalten.
--> -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top"> <Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Button Name="btnRefresh" Content="Aktualisieren" /> <Button Name="btnRefresh" Content="Aktualisieren" />
@@ -51,4 +46,4 @@
</DataGrid> </DataGrid>
</DockPanel> </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, /// 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. /// „Aktiv" schaltet den Job über die Bindung. Layout vollständig in JobsWindow.axaml.
/// </summary> /// </summary>
public partial class JobsWindow : Window public partial class JobsWindow : UserControl
{ {
public JobsWindow() => AvaloniaXamlLoader.Load(this); 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() public JobsWindow(IModuleUiHost host, JobManager jobManager) : this()
{ {
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.jobs", this);
var grid = this.FindControl<DataGrid>("gridJobs")!; var grid = this.FindControl<DataGrid>("gridJobs")!;
grid.ItemsSource = jobManager.Jobs; grid.ItemsSource = jobManager.Jobs;
@@ -1,21 +1,19 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:shell="using:PolyTrader.App.Avalonia.Shell" xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels" xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.LauncherWindow" x:Class="PolyTrader.App.Avalonia.Views.LauncherWindow">
Title="PolyTrader"
Width="1600" Height="900"
WindowState="Maximized">
<!-- <!--
Startleiste" der Anwendung. Entspricht der bisherigen LauncherForm: Startseite der Anwendung. Entspricht der bisherigen LauncherForm, ist seit der Umstellung
oben die gemeinsame Fenster-Menüleiste, darunter die Schnellaktionen (Trading-Modi), auf die Einfenster-Shell aber eine Seite unter vielen: oben die Schnellaktionen
dann die Fenster-Buttons, Account-Übersicht, Modul-KPIs und Live-Überblick-Widgets, unten die Statusleiste. (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> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top"> <Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<Button Name="btnLiveTrading" Content="LiveTrading (—)" MinWidth="170" /> <Button Name="btnLiveTrading" Content="LiveTrading (—)" MinWidth="170" />
@@ -23,17 +21,25 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom"> <!--
<StackPanel Orientation="Horizontal" Spacing="24"> D-4: Update-Hinweis. Verborgen, solange keine Prüfung lief oder keine neue Version
<TextBlock Name="lblTrading" Text="Trading: —" /> vorliegt - erscheint nur, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist UND
<TextBlock Name="lblModules" Text="Module: —" /> tatsächlich ein Update gefunden wurde. Installiert NIE automatisch (Leitentscheidung 4).
<TextBlock Name="lblRatelimit" Text="API: —" /> -->
<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> </StackPanel>
<TextBlock Name="lblUpdateInfo" Text="—" VerticalAlignment="Center" TextWrapping="Wrap" />
</DockPanel>
</Border> </Border>
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="12" Spacing="16"> <StackPanel Margin="0,14,0,0" Spacing="16">
<TextBlock Text="Fenster" FontSize="14" FontWeight="SemiBold" /> <TextBlock Text="ANSICHTEN" Classes="kicker" />
<ItemsControl Name="windowButtons"> <ItemsControl Name="windowButtons">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
@@ -42,7 +48,7 @@
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shell:WindowMenuEntry"> <DataTemplate x:DataType="shell:ViewTile">
<Button Command="{Binding Command}" <Button Command="{Binding Command}"
MinWidth="190" MinHeight="56" MinWidth="190" MinHeight="56"
Margin="0,0,8,8" Margin="0,0,8,8"
@@ -156,4 +162,4 @@
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
</Window> </UserControl>
@@ -3,17 +3,22 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading; using Avalonia.Threading;
using Deploymentcenter.Client;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PolyTrader.App.Avalonia.Shell; using PolyTrader.App.Avalonia.Shell;
using PolyTrader.App.Avalonia.ViewModels; using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core;
using PolyTrader.Core.Analytics; using PolyTrader.Core.Analytics;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity; using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence; using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Supervisor.Persistence; using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp; using PolyTraderSharp;
using PolyTraderSharp.Models; using PolyTraderSharp.Models;
@@ -22,17 +27,23 @@ using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Views namespace PolyTrader.App.Avalonia.Views
{ {
/// <summary> /// <summary>
/// Startleiste" der Anwendung Gegenstück zur bisherigen LauncherForm. Öffnet die Fenster, /// Startseite der Anwendung Gegenstück zur bisherigen LauncherForm. Führt zu den übrigen
/// spiegelt deren Offen-Status, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets. /// Ansichten, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets.
/// Layout vollständig in LauncherWindow.axaml. /// 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> /// </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 IServiceProvider _services;
private readonly TradingState _state; private readonly TradingState _state;
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) }; 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<AccountOverviewRow> _accountRows = new();
private readonly ObservableCollection<ModuleKpiTile> _kpiTiles = new(); private readonly ObservableCollection<ModuleKpiTile> _kpiTiles = new();
private readonly ObservableCollection<NotableTradeRow> _notableRows = new(); private readonly ObservableCollection<NotableTradeRow> _notableRows = new();
@@ -40,17 +51,17 @@ namespace PolyTrader.App.Avalonia.Views
private int _statusTicks; 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() => AvaloniaXamlLoader.Load(this);
public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider services) : this() public LauncherWindow(IModuleUiHost uiHost, IServiceProvider services) : this()
{ {
_uiHost = uiHost; _uiHost = uiHost;
_services = services; _services = services;
_state = services.GetRequiredService<TradingState>(); _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<ItemsControl>("windowButtons")!.ItemsSource = _windowButtons;
this.FindControl<DataGrid>("gridAccounts")!.ItemsSource = _accountRows; this.FindControl<DataGrid>("gridAccounts")!.ItemsSource = _accountRows;
this.FindControl<ItemsControl>("kpiTiles")!.ItemsSource = _kpiTiles; this.FindControl<ItemsControl>("kpiTiles")!.ItemsSource = _kpiTiles;
@@ -71,27 +82,24 @@ namespace PolyTrader.App.Avalonia.Views
UpdateTradingToggles(); UpdateTradingToggles();
}; };
this.FindControl<Button>("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
this.FindControl<Button>("btnDismissUpdate")!.Click += (_, _) =>
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = false;
_statusTimer.Tick += (_, _) => UpdateStatus(); _statusTimer.Tick += (_, _) => UpdateStatus();
_statusTimer.Start(); _statusTimer.Start();
UpdateStatus(); UpdateStatus();
RefreshData(); RefreshData();
// Schliessen des Launchers laeuft ueber dieselbe Sicherheitsabfrage wie "Beenden". // D-4: einmalige, stille Prüfung beim Start - fire-and-forget, darf den Start nie
Closing += (_, e) => // verzögern oder blockieren. Nur aktiv, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist.
{ _ = CheckForUpdateAsync();
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;
Closed += (_, _) => // 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
_statusTimer.Stop(); // Gegenstueck zu Window.Closed gibt es nicht (Sicherheitsabfrage und Beenden liegen
_uiHost.OpenStateChanged -= OnOpenStateChanged; // jetzt im ShellWindow).
ThemeManager.ThemeChanged -= UpdateTradingToggles; ThemeManager.ThemeChanged += UpdateTradingToggles;
};
} }
/// <summary>Dreistufiger Wechsel wie bisher: Inactive -> SellOnly -> Active -> Inactive.</summary> /// <summary>Dreistufiger Wechsel wie bisher: Inactive -> SellOnly -> Active -> Inactive.</summary>
@@ -108,14 +116,20 @@ namespace PolyTrader.App.Avalonia.Views
else Dispatcher.UIThread.Post(RebuildWindowButtons); 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() private void RebuildWindowButtons()
{ {
_windowButtons.Clear(); _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; string id = view.Id;
bool open = _uiHost.IsOpen(id); bool open = _uiHost.IsOpen(id);
_windowButtons.Add(new WindowMenuEntry _windowButtons.Add(new ViewTile
{ {
Title = view.Title, Title = view.Title,
Icon = ViewIcons.Resolve(view.IconKey), 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() 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(); UpdateTradingToggles();
// Account-Übersicht + Widgets alle 30 s aktualisieren // Account-Übersicht + Widgets alle 30 s aktualisieren
@@ -175,6 +182,110 @@ namespace PolyTrader.App.Avalonia.Views
UpdateSupervisor(); 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 ===== // ===== Account-Übersicht =====
private void LoadAccountOverview() private void LoadAccountOverview()
@@ -1,17 +1,12 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:acc="using:PolyTrader.Modules.Accounting.Models" xmlns:acc="using:PolyTrader.Modules.Accounting.Models"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels" xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow" x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow">
Title="Accounting"
Width="1571" Height="1067">
<!-- Drei Tabs (Übersicht/BWA, Ledger, Abruf/Status), Aufbau nach <!-- Drei Tabs (Übersicht/BWA, Ledger, Abruf/Status), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md. KPI-Kacheln zweizeilig wie zuvor. --> docs/UI-SPEZIFIKATION-WinForms.md. KPI-Kacheln zweizeilig wie zuvor. -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom"> <Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblStatus" Text="Bereit." /> <TextBlock Name="lblStatus" Text="Bereit." />
</Border> </Border>
@@ -164,4 +159,4 @@
</TabControl> </TabControl>
</DockPanel> </DockPanel>
</Window> </UserControl>
@@ -21,7 +21,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// Accounting: Periodenabrechnung mit KPI-Kacheln und Monatsübersicht, Ledger-Ansicht und /// Accounting: Periodenabrechnung mit KPI-Kacheln und Monatsübersicht, Ledger-Ansicht und
/// Abruf-/Status-Tab. Layout vollständig in AccountingWindow.axaml. /// Abruf-/Status-Tab. Layout vollständig in AccountingWindow.axaml.
/// </summary> /// </summary>
public partial class AccountingWindow : Window public partial class AccountingWindow : UserControl
{ {
private readonly ObservableCollection<MonthlyRow> _monthly = new(); private readonly ObservableCollection<MonthlyRow> _monthly = new();
private readonly ObservableCollection<LedgerEntry> _ledger = new(); private readonly ObservableCollection<LedgerEntry> _ledger = new();
@@ -43,8 +43,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
ILedgerRepository ledgerRepo, IIngestRunRepository runRepo, ILedgerRepository ledgerRepo, IIngestRunRepository runRepo,
AccountingIngestService ingest, TerminalLogger logger) : this() AccountingIngestService ingest, TerminalLogger logger) : this()
{ {
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "accounting.main", this);
_state = state; _state = state;
_reports = reports; _reports = reports;
_ledgerRepo = ledgerRepo; _ledgerRepo = ledgerRepo;
@@ -157,7 +155,12 @@ namespace PolyTrader.App.Avalonia.Views.Modules
try 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", Title = pdf ? "PDF-Export" : "CSV-Export",
SuggestedFileName = $"abrechnung_{_statement.From:yyyyMMdd}_{_statement.To:yyyyMMdd}" + (pdf ? ".pdf" : ".csv"), SuggestedFileName = $"abrechnung_{_statement.From:yyyyMMdd}_{_statement.To:yyyyMMdd}" + (pdf ? ".pdf" : ".csv"),
@@ -1,11 +1,9 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls" xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels" xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:ct="using:PolyTraderSharp.Models" xmlns:ct="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow" x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow">
Title="Copytrading"
Width="1714" Height="1167">
<!-- <!--
Ein Fenster fürs ganze Modul, vier Tabs Aufbau nach docs/UI-SPEZIFIKATION-WinForms.md. 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. Pixelbreiten sind in Sternbreiten übersetzt, damit das Fenster mitwächst.
--> -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl> <TabControl>
<TabItem Header="Master-Trader"> <TabItem Header="Master-Trader">
@@ -160,4 +156,4 @@
</TabControl> </TabControl>
</DockPanel> </DockPanel>
</Window> </UserControl>
@@ -17,7 +17,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// Copytrading-Fenster mit vier Tabs: Master-Trader, Offene Trades, Geschlossene Trades, /// Copytrading-Fenster mit vier Tabs: Master-Trader, Offene Trades, Geschlossene Trades,
/// Account-Einstellungen. Layout vollständig in CopyTradingWindow.axaml. /// Account-Einstellungen. Layout vollständig in CopyTradingWindow.axaml.
/// </summary> /// </summary>
public partial class CopyTradingWindow : Window public partial class CopyTradingWindow : UserControl
{ {
private readonly ObservableCollection<TrackedTrader> _traders = new(); private readonly ObservableCollection<TrackedTrader> _traders = new();
private readonly ObservableCollection<AccountAssignment> _assignments = new(); private readonly ObservableCollection<AccountAssignment> _assignments = new();
@@ -39,8 +39,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
ITrackedTraderRepository traderRepo, ICopyTradingAccountSettingsRepository settingsRepo, ITrackedTraderRepository traderRepo, ICopyTradingAccountSettingsRepository settingsRepo,
ICopyTradeLogRepository tradeLog) : this() ICopyTradeLogRepository tradeLog) : this()
{ {
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "copytrading.main", this);
_state = state; _state = state;
_copyState = copyState; _copyState = copyState;
_traderRepo = traderRepo; _traderRepo = traderRepo;
@@ -103,8 +101,9 @@ namespace PolyTrader.App.Avalonia.Views.Modules
gridOpen.ItemsSource = null; gridOpen.ItemsSource = _openTrades; gridOpen.ItemsSource = null; gridOpen.ItemsSource = _openTrades;
gridClosed.ItemsSource = null; gridClosed.ItemsSource = _closedTrades; 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; Shell.ThemeManager.ThemeChanged += OnTheme;
Closed += (_, _) => Shell.ThemeManager.ThemeChanged -= OnTheme;
} }
// ===== Master-Trader ===== // ===== Master-Trader =====
@@ -1,16 +1,12 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls" xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models" xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models"
x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow" x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow">
Title="ResolutionFarming"
Width="1429" Height="1030">
<!-- Vier Tabs (Kandidaten / Positionen / Historie+Statistik / Settings), Aufbau nach <!-- Vier Tabs (Kandidaten / Positionen / Historie+Statistik / Settings), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md: je Tab ein Grid mit „Aktualisieren", Konto-Auswahl oben. --> docs/UI-SPEZIFIKATION-WinForms.md: je Tab ein Grid mit „Aktualisieren", Konto-Auswahl oben. -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top"> <Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Konto:" VerticalAlignment="Center" /> <TextBlock Text="Konto:" VerticalAlignment="Center" />
@@ -94,4 +90,4 @@
</TabControl> </TabControl>
</DockPanel> </DockPanel>
</Window> </UserControl>
@@ -15,7 +15,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
/// ResolutionFarming: Kandidaten, offene Positionen, Historie und Einstellungen je Konto. /// ResolutionFarming: Kandidaten, offene Positionen, Historie und Einstellungen je Konto.
/// Layout vollständig in ResolutionFarmingWindow.axaml. /// Layout vollständig in ResolutionFarmingWindow.axaml.
/// </summary> /// </summary>
public partial class ResolutionFarmingWindow : Window public partial class ResolutionFarmingWindow : UserControl
{ {
private readonly ObservableCollection<RfCandidate> _candidates = new(); private readonly ObservableCollection<RfCandidate> _candidates = new();
private readonly ObservableCollection<RfPosition> _positions = new(); private readonly ObservableCollection<RfPosition> _positions = new();
@@ -35,8 +35,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
IRfCandidateRepository candidateRepo, IRfPositionRepository positionRepo, IRfCandidateRepository candidateRepo, IRfPositionRepository positionRepo,
IRfClosedTradeRepository closedRepo, IRfSettingsRepository settingsRepo) : this() IRfClosedTradeRepository closedRepo, IRfSettingsRepository settingsRepo) : this()
{ {
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "resolutionfarming.main", this);
_state = state; _state = state;
_candidateRepo = candidateRepo; _candidateRepo = candidateRepo;
_positionRepo = positionRepo; _positionRepo = positionRepo;
@@ -1,20 +1,15 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels" xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence" xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence"
xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual" xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual"
xmlns:svc="using:PolyTrader.Modules.Supervisor.Services" xmlns:svc="using:PolyTrader.Modules.Supervisor.Services"
x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow" x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow">
Title="Supervisor"
Width="1786" Height="1163">
<!-- Vier Tabs (Analyse / Dossiers / Berichte / Counterfactual), Aufbau nach <!-- Vier Tabs (Analyse / Dossiers / Berichte / Counterfactual), Aufbau nach
docs/UI-SPEZIFIKATION-WinForms.md. Der Analyse-Tab ist chat-artig: Verlauf oben, docs/UI-SPEZIFIKATION-WinForms.md. Der Analyse-Tab ist chat-artig: Verlauf oben,
Eingabe unten, Profil- und Modellauswahl in der Werkzeugleiste. --> Eingabe unten, Profil- und Modellauswahl in der Werkzeugleiste. -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl> <TabControl>
<TabItem Header="Analyse"> <TabItem Header="Analyse">
@@ -126,4 +121,4 @@
</TabControl> </TabControl>
</DockPanel> </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 /// Supervisor: Analyse-Chat gegen den Agenten, Dossiers je Signal, gespeicherte Berichte und
/// Counterfactuals. Layout vollständig in SupervisorWindow.axaml. /// Counterfactuals. Layout vollständig in SupervisorWindow.axaml.
/// </summary> /// </summary>
public partial class SupervisorWindow : Window public partial class SupervisorWindow : UserControl
{ {
private readonly ObservableCollection<ChatEntry> _chat = new(); private readonly ObservableCollection<ChatEntry> _chat = new();
private readonly ObservableCollection<SignalSummary> _signals = new(); private readonly ObservableCollection<SignalSummary> _signals = new();
@@ -38,8 +38,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
ISupervisorReportRepository reportRepo, ISupervisorCounterfactualRepository cfRepo, ISupervisorReportRepository reportRepo, ISupervisorCounterfactualRepository cfRepo,
TerminalLogger logger) : this() TerminalLogger logger) : this()
{ {
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "supervisor.main", this);
_agent = agent; _agent = agent;
_dossiers = dossiers; _dossiers = dossiers;
_reportRepo = reportRepo; _reportRepo = reportRepo;
@@ -1,10 +1,8 @@
<Window xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls" xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:models="using:PolyTraderSharp.Models" xmlns:models="using:PolyTraderSharp.Models"
x:Class="PolyTrader.App.Avalonia.Views.SettingsWindow" x:Class="PolyTrader.App.Avalonia.Views.SettingsWindow">
Title="Server Settings"
Width="1185" Height="820">
<!-- <!--
Einstellungen. Ersetzt die beiden PropertyGrids der WinForms-Fassung durch den Einstellungen. Ersetzt die beiden PropertyGrids der WinForms-Fassung durch den
@@ -12,8 +10,6 @@
Erklärung klein darunter). Erklärung klein darunter).
--> -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="statusbar" DockPanel.Dock="Bottom"> <Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Name="lblStatus" Text="Bereit." /> <TextBlock Name="lblStatus" Text="Bereit." />
</Border> </Border>
@@ -35,6 +31,11 @@
ToolTip.Tip="Speichert den Watchdog-Agent-Token (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt)." /> ToolTip.Tip="Speichert den Watchdog-Agent-Token (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt)." />
<Button Name="btnTestWatchdog" Content="Test-Heartbeat senden" <Button Name="btnTestWatchdog" Content="Test-Heartbeat senden"
ToolTip.Tip="Sendet sofort einen Heartbeat an den konfigurierten Watchdog-Server." /> 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> </StackPanel>
</Border> </Border>
@@ -72,4 +73,4 @@
</TabControl> </TabControl>
</DockPanel> </DockPanel>
</Window> </UserControl>
@@ -2,11 +2,13 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Deploymentcenter.Client;
using PolyTrader.App.Avalonia.Controls; using PolyTrader.App.Avalonia.Controls;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity; using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence; using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security; using PolyTrader.Core.Security;
@@ -21,7 +23,7 @@ namespace PolyTrader.App.Avalonia.Views
/// den <see cref="SettingsEditor"/> anstelle der früheren PropertyGrids. /// den <see cref="SettingsEditor"/> anstelle der früheren PropertyGrids.
/// Layout vollständig in SettingsWindow.axaml. /// Layout vollständig in SettingsWindow.axaml.
/// </summary> /// </summary>
public partial class SettingsWindow : Window public partial class SettingsWindow : UserControl
{ {
private const string SettingsPath = "server_settings.xml"; private const string SettingsPath = "server_settings.xml";
@@ -33,20 +35,20 @@ namespace PolyTrader.App.Avalonia.Views
private WatchdogHeartbeatService? _watchdog; private WatchdogHeartbeatService? _watchdog;
private MullvadVpnService? _vpn; private MullvadVpnService? _vpn;
private TerminalLogger? _logger; private TerminalLogger? _logger;
private DeploymentcenterOptions? _dcOptions;
public SettingsWindow() => AvaloniaXamlLoader.Load(this); public SettingsWindow() => AvaloniaXamlLoader.Load(this);
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state, public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null, 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; _accountRepo = accountRepo;
_state = state; _state = state;
_logger = logger; _logger = logger;
_watchdog = watchdog; _watchdog = watchdog;
_vpn = vpn; _vpn = vpn;
_dcOptions = dcOptions;
var grid = this.FindControl<DataGrid>("gridAccounts")!; var grid = this.FindControl<DataGrid>("gridAccounts")!;
grid.ItemsSource = _accounts; grid.ItemsSource = _accounts;
@@ -59,6 +61,8 @@ namespace PolyTrader.App.Avalonia.Views
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync(); this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync(); this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
this.FindControl<Button>("btnTestWatchdog")!.Click += async (_, _) => await TestWatchdogAsync(); 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>("btnAccNew")!.Click += (_, _) => AddAccount();
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync(); 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 /// 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. /// greift die NTFS-Vererbung, dort ist nichts zu tun.
/// </summary> /// </summary>
private void RestrictToOwner(string path) private void RestrictToOwner(string path) =>
{ FilePermissions.RestrictToOwner(path, msg => _logger?.Warning($"⚠️ {msg}"));
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex)
{
_logger?.Warning($"⚠️ Dateirechte für {Path.GetFileName(path)} konnten nicht gesetzt werden: {ex.Message}. " +
"Bitte manuell prüfen (chmod 600).");
}
}
// ===== OpenRouter-API-Key (Supervisor) ===== // ===== OpenRouter-API-Key (Supervisor) =====
@@ -323,5 +316,111 @@ namespace PolyTrader.App.Avalonia.Views
btn.IsEnabled = true; 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" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels" xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.TerminalWindow" x:Class="PolyTrader.App.Avalonia.Views.TerminalWindow">
Title="Terminal / Logs"
Width="1429" Height="1083">
<!-- <!--
Terminal- und Log-Ansicht. Terminal- und Log-Ansicht.
@@ -17,8 +14,6 @@
Zeilen verdrängt, der sichtbare Verlauf bleibt erhalten. Zeilen verdrängt, der sichtbare Verlauf bleibt erhalten.
--> -->
<DockPanel> <DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl> <TabControl>
<TabItem Header="Live"> <TabItem Header="Live">
@@ -94,4 +89,4 @@
</TabControl> </TabControl>
</DockPanel> </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 /// Terminal-/Log-Ansicht. Live-Ausgabe des <see cref="TerminalLogger"/> plus Viewer für die
/// JSONL-Tagesdateien. Layout vollständig in TerminalWindow.axaml. /// JSONL-Tagesdateien. Layout vollständig in TerminalWindow.axaml.
/// </summary> /// </summary>
public partial class TerminalWindow : Window public partial class TerminalWindow : UserControl
{ {
/// <summary> /// <summary>
/// Obergrenze der angezeigten Zeilen. Die WinForms-Fassung leerte bei Erreichen einer /// 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() public TerminalWindow(IModuleUiHost host, TerminalLogger logger) : this()
{ {
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.terminal", this);
_logger = logger; _logger = logger;
this.FindControl<ItemsControl>("logLines")!.ItemsSource = _lines; this.FindControl<ItemsControl>("logLines")!.ItemsSource = _lines;
this.FindControl<DataGrid>("gridLogs")!.ItemsSource = _viewerRows; this.FindControl<DataGrid>("gridLogs")!.ItemsSource = _viewerRows;
@@ -94,11 +92,10 @@ namespace PolyTrader.App.Avalonia.Views
_timer.Tick += (_, _) => ProcessQueue(); _timer.Tick += (_, _) => ProcessQueue();
_timer.Start(); _timer.Start();
Closed += (_, _) => // 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
_timer.Stop(); // Takt bleibt deshalb absichtlich an, damit der Verlauf beim Zurückwechseln vollständig
if (_logger != null && _logHandler != null) _logger.OnLogMessage -= _logHandler; // ist und keine Meldungen fehlen.
};
ProcessQueue(); ProcessQueue();
} }
@@ -166,7 +163,9 @@ namespace PolyTrader.App.Avalonia.Views
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var l in _lines) sb.AppendLine(l.Text); 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()); if (clipboard != null) await clipboard.SetTextAsync(sb.ToString());
} }
catch catch
@@ -3,6 +3,20 @@
"_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.", "_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.",
"MySqlConnectionString": "" "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": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "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")] [Category("Watchdog Monitoring")]
[DisplayName("Watchdog Enabled")] [DisplayName("Watchdog Enabled")]
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den externen " + [Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den Watchdog " +
"Watchdog-Server. Bleiben die Heartbeats aus, alarmiert der Watchdog (Dead-Man's-Switch). " + "des Deploymentcenters. Bleiben die Heartbeats aus, alarmiert der Watchdog " +
"Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")] "(Dead-Man's-Switch). Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
public bool WatchdogEnabled { get; set; } = false; public bool WatchdogEnabled { get; set; } = false;
[Category("Watchdog Monitoring")] [Category("Watchdog Monitoring")]
[DisplayName("Watchdog URL")] [DisplayName("Deploymentcenter-URL")]
[Description("Basis-URL des Watchdog-Servers, z.B. https://watchdog.example.de. " + [Description("Basis-URL des Deploymentcenters, z.B. https://dc.mhdf.de (ersetzt den früheren " +
"HTTPS ist Pflicht http:// wird nur für localhost akzeptiert, damit der Agent-Token " + "eigenständigen Watchdog-Server). HTTPS ist Pflicht http:// wird nur für localhost " +
"nicht im Klartext über das Netz geht.")] "akzeptiert, damit der Agent-Token nicht im Klartext über das Netz geht.")]
public string WatchdogUrl { get; set; } = ""; public string WatchdogUrl { get; set; } = "";
/// <summary> /// <summary>
+24 -19
View File
@@ -4,21 +4,26 @@ using System.Collections.Generic;
namespace PolyTrader.Core.Modularity namespace PolyTrader.Core.Modularity
{ {
/// <summary> /// <summary>
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View /// Eine vom Core oder einem Modul beigesteuerte Ansicht. Die Shell zeigt je View höchstens eine
/// höchstens eine Instanz und holt ein bereits offenes Fenster wieder in den Vordergrund. /// Instanz und holt eine bereits erzeugte wieder nach vorn.
/// ///
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>, /// <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 /// keinen konkreten Anzeigetyp. Dadurch bleibt der Core plattformunabhängig und trägt keine
/// trägt keine WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet: /// WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet: die frühere
/// die WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die künftige Avalonia-Shell auf /// WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die Avalonia-Shell auf
/// <c>Avalonia.Controls.Window</c>. Module liefern das Fenster, die Shell hängt es auf.</para> /// <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> /// </summary>
public sealed class ModuleView 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(); 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"; public string Title { get; init; } = "Fenster";
/// <summary>Optionale Gruppierung (z.B. "Core", "CopyTrading").</summary> /// <summary>Optionale Gruppierung (z.B. "Core", "CopyTrading").</summary>
@@ -37,7 +42,7 @@ namespace PolyTrader.Core.Modularity
public string? IconKey { get; set; } public string? IconKey { get; set; }
/// <summary> /// <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. /// <see cref="object"/> siehe Klassen-Doku zur Toolkit-Neutralität.
/// </summary> /// </summary>
public Func<object> CreateView { get; init; } = () => new object(); public Func<object> CreateView { get; init; } = () => new object();
@@ -45,9 +50,9 @@ namespace PolyTrader.Core.Modularity
/// <summary> /// <summary>
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten. /// 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. /// Die Shell zeigt eine Ansicht auf Anforderung an und verwaltet ihren Zustand. Über die
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core /// Navigations-Mitglieder kann JEDE Ansicht (auch Modul-Ansichten, die nur den Core kennen)
/// kennen) das gemeinsame „Fenster"-Menü bauen. /// zu einer anderen wechseln oder das Herunterfahren einleiten.
/// </summary> /// </summary>
public interface IModuleUiHost public interface IModuleUiHost
{ {
@@ -56,23 +61,23 @@ namespace PolyTrader.Core.Modularity
/// <summary>Alle registrierten Ansichten (Core + Module).</summary> /// <summary>Alle registrierten Ansichten (Core + Module).</summary>
IReadOnlyList<ModuleView> Views { get; } 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); 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); void OpenView(string viewId);
/// <summary>Holt das Hauptfenster (Launcher) in den Vordergrund.</summary> /// <summary>Zurück zur Startansicht.</summary>
void ActivateMain(); void ActivateMain();
/// <summary> /// <summary>
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Wird vom „Beenden"- /// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Aus jeder Ansicht
/// Eintrag JEDES Fensters aufgerufen auch aus Modul-Fenstern, die die App nicht kennen. /// aufrufbar auch aus Modul-Ansichten, die die App nicht kennen. Die konkrete Shell zeigt
/// Die konkrete Shell zeigt den Dialog und fährt bei Bestätigung geordnet herunter. /// den Dialog und fährt bei Bestätigung geordnet herunter.
/// </summary> /// </summary>
void RequestShutdown(); 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; event Action? OpenStateChanged;
} }
} }
@@ -20,6 +20,13 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Nethereum.Web3" Version="6.1.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 <!-- 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 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 (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;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using PolyTrader.Core;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Security; using PolyTrader.Core.Security;
using PolyTraderSharp.Models; using PolyTraderSharp.Models;
@@ -13,16 +16,25 @@ namespace PolyTraderSharp.Services
public sealed record WatchdogSendResult(bool Success, string Detail); public sealed record WatchdogSendResult(bool Success, string Detail);
/// <summary> /// <summary>
/// Sendet im festen Takt einen Heartbeat an den externen Watchdog-Server (Dead-Man's-Switch). /// Sendet im festen Takt einen Heartbeat an den Watchdog des Deploymentcenters
/// Bleiben die Heartbeats aus, alarmiert der Watchdog er läuft bewusst auf einer anderen /// (Dead-Man's-Switch). Bleiben die Heartbeats aus, alarmiert der Watchdog er läuft bewusst
/// Maschine und bemerkt damit auch einen harten Server-Crash. /// 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 /// **Grundregel:** Ein Ausfall des Watchdogs darf PolyTrader NIEMALS beeinträchtigen. Deshalb
/// ist jeder Sendeversuch vollständig gekapselt; aus <see cref="ExecuteAsync"/> darf keine /// ist jeder Sendeversuch vollständig gekapselt; aus <see cref="ExecuteAsync"/> darf keine
/// Ausnahme entweichen (ein werfender BackgroundService würde den Host beenden). /// Ausnahme entweichen (ein werfender BackgroundService würde den Host beenden).
/// ///
/// Bewusst eine eigene Implementierung statt des mitgelieferten Watchdog-Testclients: dieser /// Bewusst eine eigene Implementierung statt des mitgelieferten Deploymentcenter-Testclients:
/// deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und /// 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 /// <c>http://</c> ist nur für localhost erlaubt, damit der Agent-Token nicht im Klartext
/// über das Netz geht. /// über das Netz geht.
/// </summary> /// </summary>
@@ -65,6 +77,16 @@ namespace PolyTraderSharp.Services
private string? _lastAppErrorMessage; private string? _lastAppErrorMessage;
private bool _startedEventSent; 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( public WatchdogHeartbeatService(
TerminalLogger logger, TerminalLogger logger,
HttpClient? httpClient = null, HttpClient? httpClient = null,
@@ -103,6 +125,53 @@ namespace PolyTraderSharp.Services
/// <summary>Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster).</summary> /// <summary>Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster).</summary>
public void ReloadSettings() => _settings = _settingsLoader(); 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> /// <summary>
/// Prüft die aktuelle Konfiguration. <c>null</c> = sendebereit, sonst ein kurzer, /// Prüft die aktuelle Konfiguration. <c>null</c> = sendebereit, sonst ein kurzer,
/// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt. /// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt.
@@ -115,7 +184,7 @@ namespace PolyTraderSharp.Services
return "Kein Agent-Token konfiguriert."; return "Kein Agent-Token konfiguriert.";
if (string.IsNullOrWhiteSpace(_settings.WatchdogSource)) if (string.IsNullOrWhiteSpace(_settings.WatchdogSource))
return "Keine Source konfiguriert."; 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 urlProblem;
return null; return null;
} }
@@ -176,7 +245,7 @@ namespace PolyTraderSharp.Services
// den Programmstart; danach übernimmt die Staleness-Erkennung). // den Programmstart; danach übernimmt die Staleness-Erkennung).
if (!_startedEventSent) if (!_startedEventSent)
{ {
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{AppVersion})", stoppingToken) await SendEventAsync("started", "info", $"PolyTrader gestartet (v{BuildInfo.Version})", stoppingToken)
.ConfigureAwait(false); .ConfigureAwait(false);
_startedEventSent = true; _startedEventSent = true;
} }
@@ -206,9 +275,12 @@ namespace PolyTraderSharp.Services
} }
/// <summary> /// <summary>
/// B.3: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende („stopped_graceful"), /// B.3/D-1: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende. Zwei Wege, weil sie
/// damit dieser NICHT fälschlich einen Absturz alarmiert. Best effort mit kurzem Timeout /// unterschiedliche Dinge tun: der Heartbeat mit <c>status:"stopped"</c> ist das, was der
/// blockiert das Beenden nicht nennenswert. /// 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> /// </summary>
public override async Task StopAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken)
{ {
@@ -216,7 +288,11 @@ namespace PolyTraderSharp.Services
{ {
try 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) await SendEventAsync("stopped_graceful", "info", "PolyTrader wird geordnet beendet.", cts.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -242,7 +318,7 @@ namespace PolyTraderSharp.Services
string? problem = GetConfigurationProblem(); string? problem = GetConfigurationProblem();
if (problem != null) return new WatchdogSendResult(false, problem); 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); return new WatchdogSendResult(false, urlProblem);
string token; string token;
@@ -268,7 +344,11 @@ namespace PolyTraderSharp.Services
// "interval"/"expected_interval_sec" und ignoriert "intervalSeconds" still. // "interval"/"expected_interval_sec" und ignoriert "intervalSeconds" still.
interval = EffectiveIntervalSeconds(), interval = EffectiveIntervalSeconds(),
group = "Applications", 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 try
@@ -277,7 +357,9 @@ namespace PolyTraderSharp.Services
{ {
Content = JsonContent.Create(payload) 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); using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
@@ -330,15 +412,15 @@ namespace PolyTraderSharp.Services
} }
/// <summary> /// <summary>
/// B.3: Sendet ein Lifecycle-Event an <c>/api/event</c> (z. B. „started", „stopped_graceful"). /// B.3: Sendet ein Lifecycle-Event an <c>/api/watchdog/v1/event</c> (z. B. „started",
/// Best effort wirft nie und blockiert das Herunterfahren nicht. /// „stopped_graceful"). Best effort wirft nie und blockiert das Herunterfahren nicht.
/// </summary> /// </summary>
public async Task<WatchdogSendResult> SendEventAsync(string kind, string severity, string? message, CancellationToken ct = default) public async Task<WatchdogSendResult> SendEventAsync(string kind, string severity, string? message, CancellationToken ct = default)
{ {
string? problem = GetConfigurationProblem(); string? problem = GetConfigurationProblem();
if (problem != null) return new WatchdogSendResult(false, problem); 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); return new WatchdogSendResult(false, urlProblem);
string token; string token;
@@ -357,7 +439,7 @@ namespace PolyTraderSharp.Services
try try
{ {
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) }; 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); using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
return response.IsSuccessStatusCode return response.IsSuccessStatusCode
? new WatchdogSendResult(true, $"HTTP {(int)response.StatusCode}") ? new WatchdogSendResult(true, $"HTTP {(int)response.StatusCode}")
@@ -390,9 +472,10 @@ namespace PolyTraderSharp.Services
/// </summary> /// </summary>
internal static string Sanitize(string? message) internal static string Sanitize(string? message)
{ {
if (string.IsNullOrEmpty(message)) return string.Empty; // D-3: Musterprüfung selbst liegt jetzt in SecretRedactor - gemeinsam mit dem
string s = System.Text.RegularExpressions.Regex.Replace(message, "0x[0-9a-fA-F]{8,}", "0x…"); // Fehler-Reporter genutzt, der dieselben Muster aber ohne Zeilen-/Längenlimit braucht
s = System.Text.RegularExpressions.Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…"); // (ein Stacktrace ist von Natur aus mehrzeilig und soll lesbar bleiben).
string s = SecretRedactor.RemoveSecrets(message);
s = s.Replace("\r", " ").Replace("\n", " ").Trim(); s = s.Replace("\r", " ").Replace("\n", " ").Trim();
return s.Length > 200 ? s.Substring(0, 197) + "…" : s; return s.Length > 200 ? s.Substring(0, 197) + "…" : s;
} }
@@ -401,11 +484,17 @@ namespace PolyTraderSharp.Services
private int EffectiveIntervalSeconds() => private int EffectiveIntervalSeconds() =>
Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds); 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> /// <summary>
/// Baut die Ziel-URL und erzwingt TLS. <c>http://</c> ist ausschließlich für localhost /// 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. /// erlaubt (lokales Testen); sonst würde der Agent-Token im Klartext übertragen.
/// </summary> /// </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; uri = null;
problem = string.Empty; problem = string.Empty;
@@ -425,12 +514,8 @@ namespace PolyTraderSharp.Services
return false; return false;
} }
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{action}"); uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{relativePath}");
return true; 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";
} }
} }
@@ -0,0 +1,264 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Core.Configuration;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für D-3 (Fehler-Reporting an das Deploymentcenter). Kernpunkte, die hier
/// fixiert werden: TLS-Zwang wie beim Watchdog, korrekter Endpunkt/Header, Secrets werden aus
/// Nachricht UND Stacktrace entfernt, lokale Ratenbegrenzung, und die drei Fehlerquellen
/// (App-Log, AppDomain.UnhandledException, TaskScheduler.UnobservedTaskException) melden mit
/// dem jeweils richtigen <c>level</c>.
/// </summary>
public class DeploymentcenterErrorReporterTests
{
// ----- Grundfunktion -----
[Fact]
public async Task ReportAsync_posts_expected_payload_and_auth_header()
{
var reporter = Build(out FakeHandler handler, Enabled());
bool ok = await reporter.ReportAsync("System.InvalidOperationException", "Etwas kaputt", "at Foo.Bar()", "error");
Assert.True(ok);
Assert.Equal("https://dc.example.de/api/errors/v1/report", handler.LastRequest!.RequestUri!.AbsoluteUri);
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("secret-token", handler.LastRequest.Headers.Authorization!.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
var root = doc.RootElement;
Assert.Equal("polytrader", root.GetProperty("project_slug").GetString());
Assert.Equal("System.InvalidOperationException", root.GetProperty("exception").GetString());
Assert.Equal("Etwas kaputt", root.GetProperty("message").GetString());
Assert.Equal("at Foo.Bar()", root.GetProperty("stack_trace").GetString());
Assert.Equal("error", root.GetProperty("level").GetString());
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("build").GetString()));
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("environment").GetString()));
}
[Fact]
public async Task ReportAsync_does_nothing_when_disabled()
{
var options = Enabled();
options.ErrorReportingEnabled = false;
var reporter = Build(out FakeHandler handler, options);
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
Assert.False(ok);
Assert.Null(handler.LastRequest);
}
[Theory]
[InlineData("http://dc.example.de")]
[InlineData("")]
public async Task ReportAsync_rejects_insecure_or_missing_url(string baseUrl)
{
var options = Enabled();
options.BaseUrl = baseUrl;
var reporter = Build(out FakeHandler handler, options);
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
Assert.False(ok);
Assert.Null(handler.LastRequest);
}
[Fact]
public async Task ReportAsync_returns_false_instead_of_throwing_on_network_error()
{
var reporter = Build(out _, Enabled(), _ => throw new HttpRequestException("Kein DNS"));
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_returns_false_on_http_error_status()
{
var reporter = Build(out _, Enabled(),
_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
Assert.False(ok);
}
// ----- Secrets entfernen -----
[Fact]
public async Task ReportAsync_redacts_secrets_from_message_and_stack_trace()
{
var reporter = Build(out FakeHandler handler, Enabled());
await reporter.ReportAsync("X",
"Wallet 0xABCDEF0123456789 kaputt, token enc:v1:ZZZZYYYYXXXX",
"at Foo(0xDEADBEEFCAFEBABE1234)\nat Bar(enc:v1:AAAABBBBCCCC)",
"error");
using var doc = JsonDocument.Parse(handler.LastBody!);
string message = doc.RootElement.GetProperty("message").GetString()!;
string stack = doc.RootElement.GetProperty("stack_trace").GetString()!;
Assert.DoesNotContain("ABCDEF0123456789", message);
Assert.DoesNotContain("ZZZZYYYY", message);
Assert.DoesNotContain("DEADBEEFCAFEBABE", stack);
Assert.DoesNotContain("AAAABBBBCCCC", stack);
}
[Fact]
public async Task ReportAsync_keeps_newlines_in_stack_trace()
{
// Anders als die kurze Heartbeat-Meldung darf ein Stacktrace mehrzeilig bleiben - sonst
// ist er zum Debuggen wertlos.
var reporter = Build(out FakeHandler handler, Enabled());
await reporter.ReportAsync("X", "msg", "at Foo()\nat Bar()\nat Baz()", "error");
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Contains("\n", doc.RootElement.GetProperty("stack_trace").GetString());
}
[Fact]
public async Task ReportAsync_truncates_very_long_stack_traces()
{
var reporter = Build(out FakeHandler handler, Enabled());
await reporter.ReportAsync("X", "msg", new string('a', 10_000), "error");
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.True(doc.RootElement.GetProperty("stack_trace").GetString()!.Length <= 4000);
}
// ----- Ratenbegrenzung -----
[Fact]
public async Task ReportAsync_stops_sending_after_local_rate_limit_is_hit()
{
var reporter = Build(out FakeHandler handler, Enabled());
int sent = 0;
for (int i = 0; i < 25; i++)
{
if (await reporter.ReportAsync("X", $"msg {i}", null, "error")) sent++;
}
// Eigene Grenze liegt bei 20/Minute - deutlich unter dem Serverlimit von 60/Minute/IP.
Assert.Equal(20, sent);
Assert.Equal(20, handler.RequestCount);
}
// ----- Quellen: App-Log -----
[Fact]
public void OnAppLog_reports_error_level_but_ignores_warning_and_info()
{
var reporter = Build(out FakeHandler handler, Enabled());
var logger = new TerminalLogger();
reporter.OnAppLog(logger, new LogMessageEventArgs("nur eine Warnung", LogLevel.Warning));
reporter.OnAppLog(logger, new LogMessageEventArgs("nur Info", LogLevel.Info));
Assert.Null(handler.LastRequest);
reporter.OnAppLog(logger, new LogMessageEventArgs("echter Fehler", LogLevel.Error));
// OnAppLog ist Fire-and-forget (synchroner Aufrufer darf nicht blockieren).
Assert.True(SpinWait.SpinUntil(() => handler.LastRequest != null, TimeSpan.FromSeconds(2)));
}
// ----- Quellen: unbeobachtete Task-Exception -----
[Fact]
public void OnUnobservedTaskException_reports_as_warning_and_marks_observed()
{
var reporter = Build(out FakeHandler handler, Enabled());
var args = new UnobservedTaskExceptionEventArgs(
new AggregateException(new InvalidOperationException("Hintergrund kaputt")));
reporter.OnUnobservedTaskException(null, args);
Assert.True(SpinWait.SpinUntil(() => handler.LastRequest != null, TimeSpan.FromSeconds(2)));
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("warning", doc.RootElement.GetProperty("level").GetString());
Assert.True(args.Observed);
}
// ----- Quellen: AppDomain.UnhandledException -----
[Fact]
public void OnUnhandledException_reports_synchronously_as_fatal()
{
var reporter = Build(out FakeHandler handler, Enabled());
var args = new UnhandledExceptionEventArgs(new InvalidOperationException("Absturz"), isTerminating: true);
// Läuft synchron (der Prozess hat keine Zeit mehr für Fire-and-forget) - nach der
// Rückkehr muss die Anfrage also bereits abgesetzt sein.
reporter.OnUnhandledException(new object(), args);
Assert.NotNull(handler.LastRequest);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("fatal", doc.RootElement.GetProperty("level").GetString());
Assert.Contains("Absturz", doc.RootElement.GetProperty("message").GetString());
}
[Fact]
public void OnUnhandledException_never_throws_even_if_sending_fails()
{
var reporter = Build(out _, Enabled(), _ => throw new HttpRequestException("Kein Netz"));
var args = new UnhandledExceptionEventArgs(new InvalidOperationException("Absturz"), isTerminating: true);
var ex = Record.Exception(() => reporter.OnUnhandledException(new object(), args));
Assert.Null(ex);
}
// ----- Helfer -----
private static DeploymentcenterOptions Enabled() => new()
{
ErrorReportingEnabled = true,
BaseUrl = "https://dc.example.de",
Token = "secret-token",
ProjectSlug = "polytrader"
};
private static DeploymentcenterErrorReporter Build(
out FakeHandler handler,
DeploymentcenterOptions options,
Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
{
handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK)));
return new DeploymentcenterErrorReporter(new TerminalLogger(), options, new HttpClient(handler));
}
private sealed class FakeHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public int RequestCount { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
RequestCount++;
LastBody = request.Content != null
? await request.Content.ReadAsStringAsync(cancellationToken)
: string.Empty;
return _responder(request);
}
}
}
}
@@ -0,0 +1,115 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using PolyTrader.Core.Security;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für D-5/D-10 (Master-Key-Erzeugung bei der Erstinstallation). Der wichtigste
/// Fall ist der negative: ein vorhandener Schlüssel darf NIEMALS überschrieben werden - das
/// würde jedes bereits verschlüsselte Secret in der Datenbank unwiederbringlich unlesbar machen.
/// </summary>
public class MasterKeyResolverTests : IDisposable
{
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "pt-masterkey-tests-" + Guid.NewGuid().ToString("N"));
public MasterKeyResolverTests() => Directory.CreateDirectory(_tempDir);
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { /* Best effort */ }
}
private string KeyFile => Path.Combine(_tempDir, "master.key");
[Fact]
public void Resolve_prefers_environment_variable_and_never_touches_the_file()
{
string? result = MasterKeyResolver.Resolve(KeyFile, () => "env-key-value");
Assert.Equal("env-key-value", result);
Assert.False(File.Exists(KeyFile));
}
[Fact]
public void Resolve_reads_existing_file_without_modifying_it()
{
File.WriteAllText(KeyFile, "existing-key-value");
var originalWriteTime = File.GetLastWriteTimeUtc(KeyFile);
string? result = MasterKeyResolver.Resolve(KeyFile, () => null);
Assert.Equal("existing-key-value", result);
Assert.Equal(originalWriteTime, File.GetLastWriteTimeUtc(KeyFile));
}
[Fact]
public void Resolve_generates_a_new_key_when_neither_variable_nor_file_exist()
{
bool generatedCallbackFired = false;
string? result = MasterKeyResolver.Resolve(KeyFile, () => null, onGenerated: _ => generatedCallbackFired = true);
Assert.False(string.IsNullOrWhiteSpace(result));
Assert.True(File.Exists(KeyFile));
Assert.True(generatedCallbackFired);
Assert.Equal(result, File.ReadAllText(KeyFile).Trim());
}
[Fact]
public void Resolve_generated_key_decodes_as_a_256_bit_key()
{
string? result = MasterKeyResolver.Resolve(KeyFile, () => null);
byte[] bytes = Convert.FromBase64String(result!);
Assert.Equal(32, bytes.Length);
}
[Fact]
public void Resolve_never_regenerates_once_a_file_exists()
{
string? first = MasterKeyResolver.Resolve(KeyFile, () => null);
string? second = MasterKeyResolver.Resolve(KeyFile, () => null);
string? third = MasterKeyResolver.Resolve(KeyFile, () => null);
Assert.Equal(first, second);
Assert.Equal(first, third);
}
[Fact]
public void Resolve_does_not_generate_when_environment_variable_is_set_even_without_a_file()
{
_ = MasterKeyResolver.Resolve(KeyFile, () => "from-env");
Assert.False(File.Exists(KeyFile));
}
[Fact]
public void Resolve_restricts_generated_file_to_owner_on_unix()
{
// Kein separates Skip-Paket eingebunden - unter Windows greift ohnehin die
// NTFS-Vererbung des Profils (siehe FilePermissions), dort gibt es nichts zu prüfen.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
MasterKeyResolver.Resolve(KeyFile, () => null);
var mode = File.GetUnixFileMode(KeyFile);
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
}
[Fact]
public void Resolve_returns_null_and_warns_instead_of_throwing_when_generation_fails()
{
// Ein Pfad in einem nicht existierenden Verzeichnis lässt File.WriteAllText scheitern.
string badPath = Path.Combine(_tempDir, "does-not-exist", "master.key");
string? warning = null;
string? result = MasterKeyResolver.Resolve(badPath, () => null, onWarning: msg => warning = msg);
Assert.Null(result);
Assert.NotNull(warning);
}
}
}
@@ -29,11 +29,11 @@ namespace PolyTrader.Tests
[InlineData("http://127.0.0.1")] [InlineData("http://127.0.0.1")]
public void TryBuildEndpoint_accepts_https_and_local_http(string baseUrl) public void TryBuildEndpoint_accepts_https_and_local_http(string baseUrl)
{ {
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out Uri? uri, out string problem); bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
Assert.True(ok, problem); Assert.True(ok, problem);
Assert.NotNull(uri); Assert.NotNull(uri);
Assert.EndsWith("/api/heartbeat", uri!.AbsoluteUri); Assert.EndsWith("/api/watchdog/v1/ping", uri!.AbsoluteUri);
} }
[Fact] [Fact]
@@ -41,7 +41,7 @@ namespace PolyTrader.Tests
{ {
// Über http:// ginge der Agent-Token im Klartext über das Netz. // Über http:// ginge der Agent-Token im Klartext über das Netz.
bool ok = WatchdogHeartbeatService.TryBuildEndpoint( bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
"http://watchdog.example.de", "heartbeat", out Uri? uri, out string problem); "http://watchdog.example.de", WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
Assert.False(ok); Assert.False(ok);
Assert.Null(uri); Assert.Null(uri);
@@ -55,7 +55,7 @@ namespace PolyTrader.Tests
[InlineData("ftp://watchdog.example.de")] [InlineData("ftp://watchdog.example.de")]
public void TryBuildEndpoint_rejects_invalid_urls(string baseUrl) public void TryBuildEndpoint_rejects_invalid_urls(string baseUrl)
{ {
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out _, out string problem); bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, WatchdogHeartbeatService.PingPath, out _, out string problem);
Assert.False(ok); Assert.False(ok);
Assert.NotEmpty(problem); Assert.NotEmpty(problem);
@@ -65,10 +65,10 @@ namespace PolyTrader.Tests
public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs() public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs()
{ {
bool ok = WatchdogHeartbeatService.TryBuildEndpoint( bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
"https://example.de/watchdog", "heartbeat", out Uri? uri, out _); "https://example.de/watchdog", WatchdogHeartbeatService.PingPath, out Uri? uri, out _);
Assert.True(ok); Assert.True(ok);
Assert.Equal("https://example.de/watchdog/api/heartbeat", uri!.AbsoluteUri); Assert.Equal("https://example.de/watchdog/api/watchdog/v1/ping", uri!.AbsoluteUri);
} }
// ----- Konfigurationsprüfung ----- // ----- Konfigurationsprüfung -----
@@ -113,8 +113,10 @@ namespace PolyTrader.Tests
Assert.True(result.Success, result.Detail); Assert.True(result.Success, result.Detail);
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
Assert.Equal("https://wd.example.de/api/heartbeat", handler.LastRequest.RequestUri!.AbsoluteUri); Assert.Equal("https://wd.example.de/api/watchdog/v1/ping", handler.LastRequest.RequestUri!.AbsoluteUri);
Assert.Equal("secret-token", Assert.Single(handler.LastRequest.Headers.GetValues("X-Watchdog-Key"))); Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("secret-token", handler.LastRequest.Headers.Authorization!.Parameter);
Assert.False(handler.LastRequest.Headers.Contains("X-Watchdog-Key"));
using var doc = JsonDocument.Parse(handler.LastBody!); using var doc = JsonDocument.Parse(handler.LastBody!);
var root = doc.RootElement; var root = doc.RootElement;
@@ -127,6 +129,65 @@ namespace PolyTrader.Tests
// Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still. // Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still.
Assert.Equal(45, root.GetProperty("interval").GetInt32()); Assert.Equal(45, root.GetProperty("interval").GetInt32());
Assert.False(root.TryGetProperty("intervalSeconds", out _)); Assert.False(root.TryGetProperty("intervalSeconds", out _));
// D-1: Version aus BuildInfo (Directory.Build.props), nicht mehr aus der Assembly-Version.
Assert.Equal(PolyTrader.Core.BuildInfo.Version, root.GetProperty("version").GetString());
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("os").GetString()));
}
[Fact]
public async Task SendHeartbeatAsync_includes_registered_checks()
{
var service = Build(out FakeHandler handler, Valid());
service.RegisterCheck("db", () => (true, null));
service.RegisterCheck("market_feed", () => (false, "Letzter Tick vor 14 min"));
await service.SendHeartbeatAsync();
using var doc = JsonDocument.Parse(handler.LastBody!);
var checks = doc.RootElement.GetProperty("checks");
Assert.True(checks.GetProperty("db").GetProperty("ok").GetBoolean());
Assert.False(checks.GetProperty("market_feed").GetProperty("ok").GetBoolean());
Assert.Equal("Letzter Tick vor 14 min", checks.GetProperty("market_feed").GetProperty("message").GetString());
}
[Fact]
public async Task SendHeartbeatAsync_omits_checks_when_none_registered()
{
var service = Build(out FakeHandler handler, Valid());
await service.SendHeartbeatAsync();
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.True(doc.RootElement.TryGetProperty("checks", out var checksProp));
Assert.Equal(JsonValueKind.Null, checksProp.ValueKind);
}
[Fact]
public async Task SendHeartbeatAsync_check_failure_reports_itself_instead_of_throwing()
{
var service = Build(out FakeHandler handler, Valid());
service.RegisterCheck("boom", () => throw new InvalidOperationException("kaputt"));
var result = await service.SendHeartbeatAsync();
Assert.True(result.Success, result.Detail); // die werfende Prüfung darf den Versand nicht verhindern
using var doc = JsonDocument.Parse(handler.LastBody!);
var boom = doc.RootElement.GetProperty("checks").GetProperty("boom");
Assert.False(boom.GetProperty("ok").GetBoolean());
Assert.Contains("kaputt", boom.GetProperty("message").GetString());
}
[Fact]
public async Task UnregisterCheck_removes_a_previously_registered_check()
{
var service = Build(out FakeHandler handler, Valid());
service.RegisterCheck("temp", () => (true, null));
service.UnregisterCheck("temp");
await service.SendHeartbeatAsync();
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("checks").ValueKind);
} }
[Fact] [Fact]
@@ -363,7 +424,8 @@ namespace PolyTrader.Tests
var result = await service.SendEventAsync("started", "info", "Test"); var result = await service.SendEventAsync("started", "info", "Test");
Assert.True(result.Success, result.Detail); Assert.True(result.Success, result.Detail);
Assert.Equal("https://wd.example.de/api/event", handler.LastRequest!.RequestUri!.AbsoluteUri); Assert.Equal("https://wd.example.de/api/watchdog/v1/event", handler.LastRequest!.RequestUri!.AbsoluteUri);
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
using var doc = JsonDocument.Parse(handler.LastBody!); using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString()); Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString());
Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString()); Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString());