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:
@@ -33,6 +33,9 @@ MongoDB/
|
||||
master.key
|
||||
openrouter.key
|
||||
|
||||
# Packager-Zugangsdaten (FTP, updateservice:publish-Token) – Vorlage in deploy/packager.config.example.json
|
||||
deploy/packager.config.json
|
||||
|
||||
# ── Logs & temporäre Dateien ─────────────────────
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
@@ -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
@@ -3,19 +3,23 @@
|
||||
<packageSources>
|
||||
<clear />
|
||||
<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).
|
||||
Bewusst ein Paket im Repo statt einer Projektreferenz nach ..\..\LicenseLabrador:
|
||||
<!-- Lokaler Feed für Pakete aus unseren eigenen Schwester-Projekten. Bewusst ein Paket im
|
||||
Repo statt einer Projektreferenz nach ..\..\Deploymentcenter bzw. ..\..\LicenseLabrador:
|
||||
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" />
|
||||
</packageSources>
|
||||
<!-- Source-Mapping verhindert Dependency Confusion: unsere LicenseLabrador.*-Pakete werden
|
||||
ausschließlich lokal aufgelöst und können nicht von einem gleichnamigen Paket auf
|
||||
nuget.org verdrängt werden. Alles andere kommt weiterhin von nuget.org. -->
|
||||
<!-- Source-Mapping verhindert Dependency Confusion: unsere eigenen Pakete werden ausschließlich
|
||||
lokal aufgelöst und können nicht von einem gleichnamigen Paket auf nuget.org verdrängt
|
||||
werden. Alles andere kommt weiterhin von nuget.org. -->
|
||||
<packageSourceMapping>
|
||||
<clear />
|
||||
<packageSource key="local">
|
||||
<package pattern="LicenseLabrador.*" />
|
||||
<package pattern="Deploymentcenter.*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.",
|
||||
"MySqlConnectionString": ""
|
||||
},
|
||||
"Deploymentcenter": {
|
||||
"_comment": "Token und LicenseKey liegen in der gitignorierten appsettings.Local.json (Secrets gehören nicht in die versionierte Datei).",
|
||||
"BaseUrl": "https://dc.mhdf.de",
|
||||
"ProjectSlug": "polytrader",
|
||||
"Channel": "prod",
|
||||
"Token": "",
|
||||
"LicenseKey": "",
|
||||
"WatchdogSource": "polytrader",
|
||||
"WatchdogInstance": "prod-1",
|
||||
"WatchdogIntervalSeconds": 60,
|
||||
"WatchdogEnabled": false,
|
||||
"ErrorReportingEnabled": false,
|
||||
"UpdateCheckEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -7,6 +7,15 @@
|
||||
> **Lizenzverwaltung, Watchdog und Error-Reporting** grundlegend. Alles, was in diesem Dokument zu
|
||||
> LicenseLabrador oder zum Watchdog-Heartbeat steht, ist damit Übergangszustand — dort nichts mehr
|
||||
> 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 **4–7 PT** statt 2–4 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 (2–4 PT) bezog sich auf die ersten beiden Punkte. Mit
|
||||
Error-Reporting, Auslieferung und Erstinstallation liegt der Umfang bei **4–7 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 · **mittel–hoch** · 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 · **klein–mittel, ü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,5–1 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,5–1 PT | ✅ 17.08.2026, live verifiziert |
|
||||
| D-4 | Auslieferung | D-0, D-2 (Schlüssel für den Paketabruf) | 1–2 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,5–1 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 4–7 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.
@@ -3,71 +3,112 @@
|
||||
x:Class="PolyTrader.App.Avalonia.App">
|
||||
|
||||
<!--
|
||||
Farbschema. RequestedThemeVariant wird NICHT hier festgelegt, sondern beim Start vom
|
||||
ThemeManager aus den Server-Settings gesetzt (hell / dunkel / dem System folgen).
|
||||
Farbschema und Designsprache. RequestedThemeVariant wird NICHT hier festgelegt, sondern beim
|
||||
Start vom ThemeManager aus den Server-Settings gesetzt (hell / dunkel / dem System folgen).
|
||||
|
||||
Alle projekteigenen Farben stehen als Token in beiden Varianten. Im XAML werden sie über
|
||||
DynamicResource gebunden – dadurch wechseln sie beim Umschalten von selbst. Nur wenige
|
||||
Stellen setzen Farben im Code (Grid-Zeilen, Umschalter); die holen sie über
|
||||
ThemeManager.Brush(...) und zeichnen sich bei ThemeChanged neu.
|
||||
|
||||
Die Werte stammen aus dem Claude-Design-Mockup „PolyTrader UI Redesign" (Design-System
|
||||
„Industry"): gedämpftes Blaugrau als Akzent, papierne Grundfläche, Haarlinien statt Kästen,
|
||||
eckige Ecken. Farbmischungen des Mockups (color-mix) sind hier ausgerechnet, weil XAML
|
||||
keine Mischfunktion kennt.
|
||||
|
||||
Bewusste Abweichung: Das Mockup färbt Gewinne im Akzentblau. PolyTrader bleibt bei Grün/Rot
|
||||
für Gewinn/Verlust – die Bedeutungsfarbe ist im Handelskontext eingeführt und trägt weiter
|
||||
als eine reine Stilentscheidung. Die Töne sind aber an die neue Palette angeglichen
|
||||
(entsättigt statt Signalfarbe).
|
||||
-->
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
|
||||
<!-- Schriften des Mockups. Avalonia probiert die Liste der Reihe nach durch: sind
|
||||
Barlow / Barlow Condensed lokal installiert, werden sie genommen, sonst fällt es
|
||||
auf die mitgelieferte Inter zurück. Damit läuft die App auch dort sauber, wo die
|
||||
Schriften fehlen (Linux-Zielsystem, Build-Server). -->
|
||||
<FontFamily x:Key="AppFontBody">Barlow, Inter, Segoe UI, sans-serif</FontFamily>
|
||||
<FontFamily x:Key="AppFontHeading">Barlow Condensed, Barlow, Inter, Segoe UI, sans-serif</FontFamily>
|
||||
<FontFamily x:Key="AppFontMono">Consolas, Menlo, DejaVu Sans Mono, monospace</FontFamily>
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- Flächen und Rahmen -->
|
||||
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#FAFAFA" />
|
||||
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#F3F3F3" />
|
||||
<SolidColorBrush x:Key="AppCardBrush" Color="#F5F5F5" />
|
||||
<SolidColorBrush x:Key="AppBorderBrush" Color="#DDDDDD" />
|
||||
<!-- Grundflächen und Rahmen -->
|
||||
<SolidColorBrush x:Key="AppBgBrush" Color="#F2F2F3" />
|
||||
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#E9E9EA" />
|
||||
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#EDEDEE" />
|
||||
<SolidColorBrush x:Key="AppCardBrush" Color="#E9E9EA" />
|
||||
<SolidColorBrush x:Key="AppBorderBrush" Color="#D0D0D1" />
|
||||
<SolidColorBrush x:Key="AppDividerBrush" Color="#D0D0D1" />
|
||||
<!-- Texte -->
|
||||
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#777777" />
|
||||
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#666666" />
|
||||
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#444444" />
|
||||
<SolidColorBrush x:Key="AppTextBrush" Color="#1D1F20" />
|
||||
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#76787A" />
|
||||
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#66686A" />
|
||||
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#444647" />
|
||||
<!-- Akzent -->
|
||||
<SolidColorBrush x:Key="AppAccentBrush" Color="#5980A6" />
|
||||
<SolidColorBrush x:Key="AppAccentAltBrush" Color="#728FAB" />
|
||||
<SolidColorBrush x:Key="AppAccentStrongBrush" Color="#416180" />
|
||||
<!-- Akzent 16 % auf der Grundfläche: Auswahl- und Aktiv-Hinterlegung -->
|
||||
<SolidColorBrush x:Key="AppAccentSoftBrush" Color="#DAE0E7" />
|
||||
<!-- Bedeutungsfarben -->
|
||||
<SolidColorBrush x:Key="AppPositiveBrush" Color="#2E7D32" />
|
||||
<SolidColorBrush x:Key="AppNegativeBrush" Color="#C62828" />
|
||||
<SolidColorBrush x:Key="AppWarningBrush" Color="#996600" />
|
||||
<!-- Zeilenfärbung der Trade-Listen (Pastell auf hellem Grund) -->
|
||||
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#F5C8C8" />
|
||||
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#D4F0D4" />
|
||||
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#8CD68C" />
|
||||
<SolidColorBrush x:Key="AppPositiveBrush" Color="#2F6B46" />
|
||||
<SolidColorBrush x:Key="AppNegativeBrush" Color="#8E3229" />
|
||||
<SolidColorBrush x:Key="AppWarningBrush" Color="#8A6420" />
|
||||
<!-- Benachrichtigungsleiste (Kante links + Symbol) -->
|
||||
<SolidColorBrush x:Key="AppNotifyWarnBrush" Color="#B5822A" />
|
||||
<SolidColorBrush x:Key="AppNotifyErrorBrush" Color="#C0392B" />
|
||||
<!-- Zeilenfärbung der Trade-Listen (gedämpft auf hellem Grund) -->
|
||||
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#EFD4D0" />
|
||||
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#D8E7DC" />
|
||||
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#A8CDB4" />
|
||||
<!-- Trading-Umschalter im Launcher -->
|
||||
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#90EE90" />
|
||||
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#FFA500" />
|
||||
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#CD5C5C" />
|
||||
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#A8CFAF" />
|
||||
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#E0B26A" />
|
||||
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#D89A92" />
|
||||
<!-- Supervisor-Analyseverlauf -->
|
||||
<SolidColorBrush x:Key="AppChatUserBrush" Color="#4682B4" />
|
||||
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#2E8B57" />
|
||||
<SolidColorBrush x:Key="AppChatUserBrush" Color="#5980A6" />
|
||||
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#4E8168" />
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<!-- Flächen und Rahmen -->
|
||||
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#2B2B2B" />
|
||||
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#333333" />
|
||||
<SolidColorBrush x:Key="AppCardBrush" Color="#2F2F2F" />
|
||||
<SolidColorBrush x:Key="AppBorderBrush" Color="#484848" />
|
||||
<!-- Grundflächen und Rahmen -->
|
||||
<SolidColorBrush x:Key="AppBgBrush" Color="#1A1C1E" />
|
||||
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#232527" />
|
||||
<SolidColorBrush x:Key="AppSurfaceAltBrush" Color="#1F2123" />
|
||||
<SolidColorBrush x:Key="AppCardBrush" Color="#232527" />
|
||||
<SolidColorBrush x:Key="AppBorderBrush" Color="#3C3E40" />
|
||||
<SolidColorBrush x:Key="AppDividerBrush" Color="#3C3E40" />
|
||||
<!-- Texte: heller, aber nicht reinweiß – sonst flimmert es auf Dunkel -->
|
||||
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#9A9A9A" />
|
||||
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#AAAAAA" />
|
||||
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#CFCFCF" />
|
||||
<SolidColorBrush x:Key="AppTextBrush" Color="#EEF0F1" />
|
||||
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#8D9092" />
|
||||
<SolidColorBrush x:Key="AppCaptionTextBrush" Color="#A2A5A7" />
|
||||
<SolidColorBrush x:Key="AppReadOnlyTextBrush" Color="#C9CCCE" />
|
||||
<!-- Akzent: auf Dunkel aufgehellt, damit der Kontrast trägt -->
|
||||
<SolidColorBrush x:Key="AppAccentBrush" Color="#7EA3C6" />
|
||||
<SolidColorBrush x:Key="AppAccentAltBrush" Color="#8FA8C2" />
|
||||
<SolidColorBrush x:Key="AppAccentStrongBrush" Color="#B5D9FD" />
|
||||
<SolidColorBrush x:Key="AppAccentSoftBrush" Color="#2A3239" />
|
||||
<!-- Bedeutungsfarben: gleiche Aussage, aufgehellt für Kontrast auf Dunkel -->
|
||||
<SolidColorBrush x:Key="AppPositiveBrush" Color="#81C784" />
|
||||
<SolidColorBrush x:Key="AppNegativeBrush" Color="#EF5350" />
|
||||
<SolidColorBrush x:Key="AppWarningBrush" Color="#FFB74D" />
|
||||
<SolidColorBrush x:Key="AppPositiveBrush" Color="#6FAE86" />
|
||||
<SolidColorBrush x:Key="AppNegativeBrush" Color="#D9756A" />
|
||||
<SolidColorBrush x:Key="AppWarningBrush" Color="#D3A45E" />
|
||||
<SolidColorBrush x:Key="AppNotifyWarnBrush" Color="#D3A45E" />
|
||||
<SolidColorBrush x:Key="AppNotifyErrorBrush" Color="#D9756A" />
|
||||
<!-- Zeilenfärbung: gedämpft statt pastell – die hellen Töne würden auf Dunkel
|
||||
blenden und den Text unlesbar machen. Reihenfolge der Aussage bleibt. -->
|
||||
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#4E2A2A" />
|
||||
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#2C3F2C" />
|
||||
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#3D6B3D" />
|
||||
<SolidColorBrush x:Key="AppTradeLossBrush" Color="#4A2B27" />
|
||||
<SolidColorBrush x:Key="AppTradeSmallWinBrush" Color="#2A3A30" />
|
||||
<SolidColorBrush x:Key="AppTradeBigWinBrush" Color="#3A5C46" />
|
||||
<!-- Trading-Umschalter -->
|
||||
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#3F7A3F" />
|
||||
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#A76B1F" />
|
||||
<SolidColorBrush x:Key="AppToggleActiveBrush" Color="#3F7A55" />
|
||||
<SolidColorBrush x:Key="AppToggleSellOnlyBrush" Color="#9A6C25" />
|
||||
<SolidColorBrush x:Key="AppToggleInactiveBrush" Color="#8E3B3B" />
|
||||
<!-- Supervisor-Analyseverlauf -->
|
||||
<SolidColorBrush x:Key="AppChatUserBrush" Color="#7FB3D8" />
|
||||
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#68B68A" />
|
||||
<SolidColorBrush x:Key="AppChatUserBrush" Color="#7EA3C6" />
|
||||
<SolidColorBrush x:Key="AppChatAgentBrush" Color="#78B49A" />
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
@@ -77,62 +118,275 @@
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
|
||||
<!-- Projektweite Grundgestaltung. Bewusst hier zentral statt je Fenster wiederholt,
|
||||
damit alle Fenster gleich aussehen (wie zuvor die einheitliche Segoe-UI-Vorgabe). -->
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
<Setter Property="IsReadOnly" Value="True" />
|
||||
<Setter Property="CanUserResizeColumns" Value="True" />
|
||||
<Setter Property="CanUserSortColumns" Value="True" />
|
||||
<Setter Property="RowHeight" Value="24" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<!-- ═══ Grundgestaltung ═══════════════════════════════════════════════════════
|
||||
Projektweit zentral statt je Fenster wiederholt, damit alle Ansichten gleich
|
||||
aussehen. Die Designsprache des Mockups lebt fast vollständig hier. -->
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Background" Value="{DynamicResource AppBgBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- KPI-Kachel: entspricht den zweizeiligen Labels der WinForms-Oberfläche
|
||||
("<Titel> \n <Wert>"), siehe docs/UI-SPEZIFIKATION-WinForms.md. -->
|
||||
<Style Selector="Border.kpi">
|
||||
<Setter Property="Background" Value="{DynamicResource AppCardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="Margin" Value="0,0,8,8" />
|
||||
<Setter Property="MinWidth" Value="150" />
|
||||
<!-- Überschriften laufen in der schmalen Schnittvariante (Barlow Condensed). -->
|
||||
<Style Selector="TextBlock.h4">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Border.kpi > StackPanel > TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppCaptionTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.kpi > StackPanel > TextBlock.value">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Style Selector="TextBlock.h5">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- Werkzeugleiste oben in jedem Fenster (Ersatz für ToolStrip). -->
|
||||
<Style Selector="Border.toolbar">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
</Style>
|
||||
<Style Selector="Border.toolbar Button">
|
||||
<Setter Property="Margin" Value="0,0,6,0" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
</Style>
|
||||
|
||||
<!-- Statusleiste unten (Ersatz für StatusStrip). -->
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<!-- „Kicker": die kleine Versalzeile in Akzentfarbe über einer Überschrift.
|
||||
Avalonia kennt kein text-transform – die Texte stehen im XAML in Versalien. -->
|
||||
<Style Selector="TextBlock.kicker">
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Gedämpfte Hinweistexte (Statuszeilen, Erklärungen unter Feldern). -->
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontMono}" />
|
||||
</Style>
|
||||
|
||||
<!-- ═══ Steuerelemente: eckig und hairline ════════════════════════════════════
|
||||
Das Mockup setzt border-radius durchgängig auf 0 und zeichnet Kästen nur mit
|
||||
einer 1px-Linie. Fluent bringt gerundete, gefüllte Flächen mit – deshalb hier
|
||||
flächendeckend zurückgesetzt. -->
|
||||
|
||||
<Style Selector="Button">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<!-- Fluent malt Füllung und Rahmen im Vorlagenteil und überschreibt sie je Zustand.
|
||||
Deshalb müssen Schwebe- und Druckzustand dort gesetzt werden, sonst blitzt beim
|
||||
Überfahren wieder das Fluent-Grau durch. -->
|
||||
<Style Selector="Button /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Hauptaktion: gefüllt im Akzent. -->
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppBgBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentStrongBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentStrongBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Rahmenlose Schaltfläche (Seitenleiste, Symbolknöpfe). -->
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox, ComboBox, NumericUpDown, CalendarDatePicker, AutoCompleteBox">
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="CheckBox, RadioButton">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="MinHeight" Value="38" />
|
||||
</Style>
|
||||
|
||||
<!-- ═══ Tabellen ══════════════════════════════════════════════════════════════
|
||||
Kopfzeile klein, gesperrt und gedämpft; Zeilen nur durch eine Haarlinie
|
||||
getrennt (keine Gitterkästen). -->
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
<Setter Property="IsReadOnly" Value="True" />
|
||||
<Setter Property="CanUserResizeColumns" Value="True" />
|
||||
<Setter Property="CanUserSortColumns" Value="True" />
|
||||
<Setter Property="RowHeight" Value="26" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<!-- Kein LetterSpacing: DataGridColumnHeader ist ein ContentControl und kennt die
|
||||
Eigenschaft nicht (nur TextBlock hat sie). Die Sperrung der Mockup-Kopfzeile
|
||||
entfällt hier deshalb bewusst. -->
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="7,5" />
|
||||
</Style>
|
||||
|
||||
<!-- ═══ Bausteine ═════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- KPI-Kachel: entspricht den zweizeiligen Labels der WinForms-Oberfläche
|
||||
("<Titel> \n <Wert>"), siehe docs/UI-SPEZIFIKATION-WinForms.md. -->
|
||||
<Style Selector="Border.kpi">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
<Setter Property="MinWidth" Value="160" />
|
||||
</Style>
|
||||
<Style Selector="Border.kpi > StackPanel > TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.kpi > StackPanel > TextBlock.value">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- Allgemeiner Rahmen-Kasten (Detailbereiche, Log-Fläche). -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- Statuskennzeichen in Listen und Kopfzeilen. -->
|
||||
<Style Selector="Border.tag">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="8,2" />
|
||||
</Style>
|
||||
|
||||
<!-- Werkzeugleiste oben in jeder Ansicht (Ersatz für ToolStrip). Im neuen Design
|
||||
ohne Füllfläche – nur die Schaltflächen stehen auf dem Grund. -->
|
||||
<Style Selector="Border.toolbar">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="0,0,0,10" />
|
||||
</Style>
|
||||
<Style Selector="Border.toolbar Button">
|
||||
<Setter Property="Margin" Value="0,0,6,0" />
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
|
||||
<!-- Statusleiste unten (Ersatz für StatusStrip). -->
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDividerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="20,5" />
|
||||
</Style>
|
||||
<Style Selector="Border.statusbar TextBlock">
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Senkrechter Trenner in Werkzeugleisten. -->
|
||||
<Style Selector="Border.sep">
|
||||
<Setter Property="Width" Value="1" />
|
||||
<Setter Property="Height" Value="18" />
|
||||
<Setter Property="Margin" Value="4,0,10,0" />
|
||||
<Setter Property="Background" Value="{DynamicResource AppDividerBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Gruppenkasten (Launcher-Widgets). Überschrift wie ein Kicker gesetzt. -->
|
||||
<Style Selector="HeaderedContentControl">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontHeading}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="GridSplitter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppDividerBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ═══ Seitenleiste der Shell ════════════════════════════════════════════════ -->
|
||||
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="9,7" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontBody}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<!-- Eingeklappt steht nur das Symbol da – dann mittig statt links. -->
|
||||
<Style Selector="Button.nav.icononly">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav.active">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppAccentBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<!-- Abschnittsüberschrift in der Seitenleiste. -->
|
||||
<Style Selector="TextBlock.navgroup">
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="LetterSpacing" Value="1.4" />
|
||||
<Setter Property="Margin" Value="9,0,0,6" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppMutedTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ═══ Benachrichtigungsleiste ═══════════════════════════════════════════════
|
||||
Farbige Kante links, sonst nur eine gedämpfte Bandfläche – wie im Mockup. -->
|
||||
<Style Selector="Border.notify">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppNotifyWarnBrush}" />
|
||||
<Setter Property="BorderThickness" Value="3,0,0,0" />
|
||||
<Setter Property="Padding" Value="17,6" />
|
||||
</Style>
|
||||
<Style Selector="Border.notify.error">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppNotifyErrorBrush}" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -42,12 +42,16 @@ namespace PolyTrader.App.Avalonia
|
||||
ModuleViews.Register(uiHost, Services);
|
||||
ViewIcons.AssignDefaults(uiHost);
|
||||
|
||||
var launcher = new LauncherWindow(uiHost, Services);
|
||||
uiHost.SetMainWindow(launcher);
|
||||
desktop.MainWindow = launcher;
|
||||
// Einfenster-Shell: ein Fenster, die Views hängen als Seiten darin.
|
||||
var shell = new ShellWindow(uiHost, Services);
|
||||
uiHost.SetShell(shell);
|
||||
desktop.MainWindow = shell;
|
||||
|
||||
// Schliessen des Launchers laeuft ueber die Sicherheitsabfrage (wie bisher das X).
|
||||
// Schliessen der Shell laeuft ueber die Sicherheitsabfrage (wie bisher das X).
|
||||
desktop.ShutdownMode = global::Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
||||
|
||||
// Erste Seite: die Startseite (frühere LauncherForm).
|
||||
uiHost.ActivateMain();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
|
||||
x:Class="PolyTrader.App.Avalonia.Controls.WindowMenuBar"
|
||||
x:DataType="shell:WindowMenuModel">
|
||||
|
||||
<!--
|
||||
Gemeinsame Fenster-Menüleiste, die auf JEDEM PolyTrader-Fenster oben sitzt.
|
||||
Alle Fenster stehen nebeneinander (nicht in einem Untermenü versteckt), das aktuelle
|
||||
fett, offene mit Haken. Die rechte Aktion ist kontextabhängig.
|
||||
|
||||
Vollständig deklarativ: die Einträge kommen als Datenmodell aus WindowMenuModel.Entries,
|
||||
hier wird nur beschrieben, wie ein Eintrag aussieht. Kein Aufbau zur Laufzeit im Code.
|
||||
(Avalonia kennt kein ToolStripItemAlignment.Right — die rechte Ausrichtung entsteht
|
||||
dadurch, dass der letzte Eintrag im DockPanel rechts angedockt wird.)
|
||||
-->
|
||||
<Border Background="{DynamicResource AppSurfaceAltBrush}" BorderBrush="{DynamicResource AppBorderBrush}" BorderThickness="0,0,0,1" Padding="4,2">
|
||||
<ItemsControl ItemsSource="{Binding Entries}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="shell:WindowMenuEntry">
|
||||
<Button Command="{Binding Command}"
|
||||
ToolTip.Tip="{Binding ToolTip}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="8,4"
|
||||
Margin="0,0,2,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Image Source="{Binding Icon}"
|
||||
Width="20" Height="20"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Icon, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Text="{Binding CheckMark}"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AppPositiveBrush}" />
|
||||
<TextBlock Text="{Binding Title}"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="{Binding FontWeight}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,30 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PolyTrader.App.Avalonia.Shell;
|
||||
using PolyTrader.Core.Modularity;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Code-Behind der Fenster-Menüleiste: nur Verdrahtung, kein Layout (siehe Layout-Regel).
|
||||
/// Jedes Fenster ruft <see cref="Attach"/> auf und bekommt damit dieselbe Leiste.
|
||||
/// </summary>
|
||||
public partial class WindowMenuBar : UserControl
|
||||
{
|
||||
private WindowMenuModel? _model;
|
||||
|
||||
public WindowMenuBar() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>
|
||||
/// Verbindet die Leiste mit der Shell. <paramref name="currentViewId"/> ist <c>null</c> für den
|
||||
/// Launcher (rechte Aktion „Beenden"), sonst die View-ID (rechte Aktion „Fenster schließen").
|
||||
/// Die Event-Registrierung wird beim Entladen wieder gelöst – kein Leak.
|
||||
/// </summary>
|
||||
public void Attach(IModuleUiHost host, string? currentViewId, Window owner)
|
||||
{
|
||||
_model = new WindowMenuModel(host, currentViewId, owner.Close);
|
||||
DataContext = _model;
|
||||
owner.Closed += (_, _) => _model?.Detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Deploymentcenter.Client;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Licensing
|
||||
{
|
||||
/// <summary>
|
||||
/// Kopflose Lizenzverwaltung von der Kommandozeile aus (D-2), nach der Konvention aus
|
||||
/// LICENSE_INTEGRATION_GUIDE §4: <c>--license-status</c>, <c>--license-set-key</c>,
|
||||
/// <c>--license-deactivate</c>. Diese Schalter baut die Anwendung selbst, das SDK bringt nur die
|
||||
/// Bausteine mit. Läuft VOR dem Host-Aufbau und beendet den Prozess über den Rückgabewert.
|
||||
/// </summary>
|
||||
internal static class LicenseCli
|
||||
{
|
||||
/// <summary>Liefert <c>true</c>, wenn einer der Lizenz-Schalter erkannt und abgearbeitet wurde.</summary>
|
||||
public static bool TryHandle(string[] args, out int exitCode)
|
||||
{
|
||||
exitCode = 0;
|
||||
|
||||
if (args.Any(a => string.Equals(a, "--license-status", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
exitCode = Status();
|
||||
return true;
|
||||
}
|
||||
|
||||
int setIdx = Array.FindIndex(args, a => string.Equals(a, "--license-set-key", StringComparison.OrdinalIgnoreCase));
|
||||
if (setIdx >= 0)
|
||||
{
|
||||
string? key = setIdx + 1 < args.Length ? args[setIdx + 1] : null;
|
||||
exitCode = SetKey(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.Any(a => string.Equals(a, "--license-deactivate", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
exitCode = Deactivate();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int Status()
|
||||
{
|
||||
var options = Program.LoadDeploymentcenterOptions();
|
||||
var hw = HardwareId.GetHardwareId(options.ProjectSlug);
|
||||
var cached = LicenseClient.TryGetCachedState(options.ProjectSlug);
|
||||
|
||||
Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})");
|
||||
Console.WriteLine($"Produkt: {options.ProjectSlug}");
|
||||
Console.WriteLine($"Server: {(string.IsNullOrWhiteSpace(options.BaseUrl) ? "(keine URL konfiguriert)" : options.BaseUrl)}");
|
||||
Console.WriteLine($"Cache: {(cached == null ? "leer" : cached.Status)}");
|
||||
|
||||
if (cached != null && cached.CacheExpiresAt > 0)
|
||||
{
|
||||
var until = DateTimeOffset.FromUnixTimeSeconds(cached.CacheExpiresAt);
|
||||
Console.WriteLine($"Offline noch bis: {until:u}");
|
||||
}
|
||||
|
||||
var settings = ServerSettings.Load("server_settings.xml");
|
||||
Console.WriteLine($"Gespeicherter Schlüssel (server_settings.xml): {(string.IsNullOrWhiteSpace(settings.LicenseKey) ? "keiner" : settings.LicenseKeyStatus)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int SetKey(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
Console.Error.WriteLine("Fehler: --license-set-key erwartet den Schlüssel als nächstes Argument.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var options = Program.LoadDeploymentcenterOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
Console.Error.WriteLine("Fehler: Keine Deploymentcenter-URL konfiguriert (appsettings.json: Deploymentcenter:BaseUrl).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
LicenseClient.DefaultAppVersion = BuildInfo.Version;
|
||||
var client = new LicenseClient();
|
||||
LicenseValidationResult result = client
|
||||
.ValidateAsync(options.ProjectSlug, key.Trim(), options.BaseUrl, BuildInfo.Version)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
Console.WriteLine($"{result.Status}: {result.Message}");
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
const string settingsPath = "server_settings.xml";
|
||||
var settings = ServerSettings.Load(settingsPath);
|
||||
settings.LicenseKey = SecretProtection.Protect(key.Trim());
|
||||
settings.Save(settingsPath);
|
||||
Console.WriteLine($"Lizenz gültig und in {settingsPath} gespeichert " +
|
||||
$"({(SecretProtection.IsEncrypted(settings.LicenseKey) ? "verschlüsselt" : "Klartext")}).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int Deactivate()
|
||||
{
|
||||
var options = Program.LoadDeploymentcenterOptions();
|
||||
var settings = ServerSettings.Load("server_settings.xml");
|
||||
|
||||
string key;
|
||||
try { key = SecretProtection.Unprotect(settings.LicenseKey); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Fehler: gespeicherter Schlüssel nicht lesbar: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
Console.Error.WriteLine("Fehler: kein Lizenzschlüssel hinterlegt.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Die Deaktivierung gibt einen Aktivierungsplatz frei und ist deshalb geschützt - der
|
||||
// shared_key gehört auf den Administrationsrechner, nicht in die ausgelieferte
|
||||
// Anwendung (siehe LICENSE_INTEGRATION_GUIDE). Ohne ihn: klarer Hinweis statt Versuch.
|
||||
string? sharedKey = Environment.GetEnvironmentVariable("DC_SHARED_KEY");
|
||||
if (string.IsNullOrWhiteSpace(sharedKey))
|
||||
{
|
||||
Console.Error.WriteLine("Fehler: DC_SHARED_KEY nicht gesetzt. Die Deaktivierung braucht den " +
|
||||
"administrativen Shared Key - alternativ im Deploymentcenter-WebUI unter " +
|
||||
"der Hardware-Liste auf „Freigeben\" klicken.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var client = new LicenseClient();
|
||||
bool ok = client.DeactivateAsync(options.ProjectSlug, key, options.BaseUrl, sharedKey)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
Console.WriteLine(ok ? "Aktivierung freigegeben." : "Deaktivierung fehlgeschlagen.");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using Deploymentcenter.Client;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Licensing
|
||||
{
|
||||
/// <summary>
|
||||
/// Lizenz-Torwächter für den Programmstart (D-2). Ersetzt den früheren, gegen
|
||||
/// <c>LicenseLabrador.Client</c> gebauten <c>Licensing/LicenseGate.cs</c> im WinForms-Projekt
|
||||
/// (siehe docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md) – dort läuft
|
||||
/// ohnehin keine Lizenzprüfung, weil Avalonia die tatsächlich benutzte Anwendung ist.
|
||||
///
|
||||
/// **Grundsatz unverändert:** kein <see cref="Environment.Exit(int)"/> bei ungültiger Lizenz. Ein
|
||||
/// Trading-Bot mit offenen Positionen darf nicht mitten im Lauf abgeschossen werden. Bei
|
||||
/// fehlender/ungültiger Lizenz startet die Core-Shell (Terminal, Einstellungen) ohne die
|
||||
/// Trading-/Analyse-Module; der Schlüssel lässt sich jederzeit über den Button
|
||||
/// „Lizenzschlüssel setzen …" im Einstellungsfenster nachtragen (wirkt ab dem nächsten Start –
|
||||
/// eine Laufzeit-Revalidierung gibt es bewusst nicht, siehe Entscheidung vom 31.07.2026).
|
||||
///
|
||||
/// **Bewusst kein eigenes <see cref="ILicensePrompt"/>:** Der interaktive Weg läuft über die
|
||||
/// bereits vorhandene, erprobte Settings-UI (derselbe maskierte Prompt-Button wie beim
|
||||
/// Watchdog-Token); ein zusätzlicher Dialog nur für die Lizenz wäre eine zweite, redundante
|
||||
/// Eingabe-Oberfläche. Für den Kopflos-Betrieb (<c>--headless</c>, CLI-Schalter) genügt die vom
|
||||
/// SDK mitgelieferte <see cref="ConsoleLicensePrompt"/> – eine eigene Fassung böte keinen Mehrwert.
|
||||
/// </summary>
|
||||
public static class LicenseGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Prüft still (ohne Dialog), ob die gespeicherte Lizenz aktuell nutzbar ist. Entscheidet
|
||||
/// darüber, ob die Trading-/Analyse-Module überhaupt registriert werden – muss deshalb vor
|
||||
/// dem Host-Aufbau laufen. Wirft nie: jeder Fehler in der Prüfung führt zum eingeschränkten
|
||||
/// Modus, nicht zum Absturz.
|
||||
/// </summary>
|
||||
public static bool CheckUsable(DeploymentcenterOptions options, ServerSettings settings, TerminalLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
logger.Warning("⚠️ Keine Deploymentcenter-URL konfiguriert. Start im eingeschränkten Modus (nur Core-Shell).");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Befund D-3 aus dem Umsetzungsplan: das SDK erzwingt selbst kein TLS. Ohne diese
|
||||
// Prüfung ginge der Lizenzschlüssel im Klartext übers Netz, wenn die URL versehentlich
|
||||
// auf http:// steht.
|
||||
if (!Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var parsed) ||
|
||||
!(parsed.Scheme == Uri.UriSchemeHttps || (parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)))
|
||||
{
|
||||
logger.Warning($"⚠️ Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{options.BaseUrl}'. " +
|
||||
"Start im eingeschränkten Modus.");
|
||||
return false;
|
||||
}
|
||||
|
||||
string key;
|
||||
try
|
||||
{
|
||||
// Erlaubt einen mit dem Master-Key verschlüsselten Schlüssel (enc:v1:…);
|
||||
// Klartext wird unverändert durchgereicht.
|
||||
key = SecretProtection.Unprotect(settings.LicenseKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"⚠️ Gespeicherter Lizenzschlüssel nicht lesbar ({ex.Message}) – behandle als 'keine Lizenz'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
logger.Warning("⚠️ Kein Lizenzschlüssel gesetzt. Start im eingeschränkten Modus (nur Core-Shell). " +
|
||||
"Schlüssel über „Lizenzschlüssel setzen …\" im Einstellungsfenster eintragen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
LicenseClient.DefaultAppVersion = BuildInfo.Version;
|
||||
var client = new LicenseClient();
|
||||
LicenseValidationResult result = client
|
||||
.ValidateAsync(options.ProjectSlug, key, options.BaseUrl, BuildInfo.Version)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
logger.Info(result.IsCached
|
||||
? $"🔑 Lizenz im Offline-Kulanzmodus gültig ({result.Status})."
|
||||
: "🔑 Lizenz gültig (online geprüft).");
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.Warning($"⚠️ Keine gültige Lizenz ({result.Status}: {result.Message}). " +
|
||||
"Start im eingeschränkten Modus (nur Core-Shell, keine Module). " +
|
||||
"Schlüssel über „Lizenzschlüssel setzen …\" im Einstellungsfenster prüfen.");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"❌ Lizenzprüfung unerwartet fehlgeschlagen: {ex.Message}. Start im eingeschränkten Modus.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Update="appsettings.Local.json" Condition="Exists('appsettings.Local.json')" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- D-5: vom Deploymentcenter-Installer gelesen (update-agent im Modus "install"). Gehört ins
|
||||
Publish-Verzeichnis, landet damit im Release-Paket neben der manifest.json. -->
|
||||
<None Update="setup.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -41,7 +41,18 @@ namespace PolyTrader.App.Avalonia
|
||||
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
|
||||
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var host = BuildHost(out var bootLog);
|
||||
// D-2: Lizenz-CLI-Schalter (--license-status/--license-set-key/--license-deactivate)
|
||||
// laufen VOR dem Host-Aufbau und beenden sich selbst - kein Sinn, dafür Trading-Dienste
|
||||
// hochzufahren.
|
||||
if (Licensing.LicenseCli.TryHandle(args, out int cliExitCode))
|
||||
return cliExitCode;
|
||||
|
||||
var dcOptions = LoadDeploymentcenterOptions();
|
||||
|
||||
// Konstruktionsprüfung (--smoke-ui) testet Fensteraufbau, keine Lizenzlogik - dort
|
||||
// bewusst ALLE Module registrieren, sonst würde die Prüfung auf einem Build-Server
|
||||
// (nie lizenziert) still einen Großteil ihrer Abdeckung verlieren.
|
||||
var host = BuildHost(dcOptions, enforceLicense: !smoke, out var bootLog);
|
||||
|
||||
// Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
|
||||
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
|
||||
@@ -82,11 +93,35 @@ namespace PolyTrader.App.Avalonia
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
/// <summary>
|
||||
/// Lädt NUR den Deploymentcenter-Konfigurationsblock, bevor der Generic Host existiert –
|
||||
/// die Lizenzprüfung (D-2) muss VOR der Modul-Liste laufen, also bevor
|
||||
/// <see cref="Host.CreateDefaultBuilder"/> seine eigene <see cref="IConfiguration"/> baut.
|
||||
/// Liest dieselben Dateien wie der Host später (appsettings.json + appsettings.Local.json),
|
||||
/// nur eigenständig und minimal. Von <see cref="Licensing.LicenseCli"/> mitbenutzt.
|
||||
/// </summary>
|
||||
internal static DeploymentcenterOptions LoadDeploymentcenterOptions()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Local.json", optional: true)
|
||||
.Build();
|
||||
|
||||
var options = new DeploymentcenterOptions();
|
||||
config.GetSection(DeploymentcenterOptions.SectionName).Bind(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
|
||||
/// aktiven Module. Kennt die Oberfläche nicht.
|
||||
/// </summary>
|
||||
private static IHost BuildHost(out TerminalLogger bootLog)
|
||||
/// <param name="enforceLicense">
|
||||
/// D-2: Wenn <c>false</c> (nur <c>--smoke-ui</c>), werden immer alle Module registriert –
|
||||
/// die Konstruktionsprüfung testet Fensteraufbau, keine Lizenzlogik.
|
||||
/// </param>
|
||||
private static IHost BuildHost(DeploymentcenterOptions dcOptions, bool enforceLicense, out TerminalLogger bootLog)
|
||||
{
|
||||
var serverSettings = ServerSettings.Load(ServerSettingsPath);
|
||||
var logger = new TerminalLogger();
|
||||
@@ -115,6 +150,16 @@ namespace PolyTrader.App.Avalonia
|
||||
new AccountingModule()
|
||||
};
|
||||
var disabled = new HashSet<string>(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// D-2: ohne gültige Lizenz startet nur die Core-Shell (Terminal, Einstellungen) - alle
|
||||
// Trading-/Analyse-Module bleiben unregistriert. Kein Environment.Exit (Entscheidung
|
||||
// 31.07.2026): der Schlüssel lässt sich im Einstellungsfenster nachtragen.
|
||||
bool licensed = !enforceLicense || Licensing.LicenseGate.CheckUsable(dcOptions, serverSettings, logger);
|
||||
if (!licensed)
|
||||
{
|
||||
foreach (var m in allModules) disabled.Add(m.Name);
|
||||
}
|
||||
|
||||
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
|
||||
|
||||
return Host.CreateDefaultBuilder()
|
||||
@@ -123,6 +168,7 @@ namespace PolyTrader.App.Avalonia
|
||||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.AddSingleton(dcOptions);
|
||||
var databaseOptions = new DatabaseOptions
|
||||
{
|
||||
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
|
||||
@@ -147,7 +193,22 @@ namespace PolyTrader.App.Avalonia
|
||||
services.AddSingleton<INotificationSink>(
|
||||
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
|
||||
|
||||
services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>()));
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var watchdog = new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>());
|
||||
|
||||
// D-1: erster "checks"-Eintrag - beweist die Mechanik mit einem real
|
||||
// verfügbaren Signal. db/market_feed folgen, sobald die jeweiligen Dienste
|
||||
// ein Gesundheitssignal nach außen geben (siehe
|
||||
// docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, D-1).
|
||||
var vpn = sp.GetRequiredService<MullvadVpnService>();
|
||||
var serverSettings = sp.GetRequiredService<ServerSettings>();
|
||||
watchdog.RegisterCheck("vpn", () => serverSettings.VpnEnabled
|
||||
? (vpn.IsConnected, vpn.IsConnected ? null : "Mullvad VPN nicht verbunden")
|
||||
: (true, null)); // VPN bewusst aus -> kein Fehlalarm
|
||||
|
||||
return watchdog;
|
||||
});
|
||||
|
||||
// MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
|
||||
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
|
||||
@@ -159,9 +220,15 @@ namespace PolyTrader.App.Avalonia
|
||||
module.RegisterServices(services, context.Configuration);
|
||||
}
|
||||
|
||||
// D-3: Fehler-Reporting - rein ereignisgesteuert (AppDomain.UnhandledException,
|
||||
// TaskScheduler.UnobservedTaskException, TerminalLogger-Fehler), kein eigener Takt.
|
||||
services.AddSingleton(sp => new DeploymentcenterErrorReporter(
|
||||
sp.GetRequiredService<TerminalLogger>(), sp.GetRequiredService<DeploymentcenterOptions>()));
|
||||
|
||||
services.AddHostedService<MarketSyncService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
|
||||
|
||||
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
|
||||
services.AddSingleton<AvaloniaUiHost>();
|
||||
@@ -189,7 +256,7 @@ namespace PolyTrader.App.Avalonia
|
||||
ViewIcons.AssignDefaults(uiHost);
|
||||
|
||||
int failures = 0;
|
||||
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ===");
|
||||
Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ===");
|
||||
foreach (var view in uiHost.Views)
|
||||
{
|
||||
try
|
||||
@@ -204,15 +271,17 @@ namespace PolyTrader.App.Avalonia
|
||||
}
|
||||
}
|
||||
|
||||
// Die Startseite steckt seit der Einfenster-Shell in der Schleife oben (core.start);
|
||||
// separat geprueft wird nur noch das Shell-Fenster selbst.
|
||||
try
|
||||
{
|
||||
_ = new Views.LauncherWindow(uiHost, host.Services);
|
||||
Console.WriteLine("[OK] LauncherWindow konstruiert");
|
||||
_ = new Views.ShellWindow(uiHost, host.Services);
|
||||
Console.WriteLine("[OK] ShellWindow konstruiert");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.WriteLine($"[FEHLER] LauncherWindow: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
@@ -255,9 +324,12 @@ namespace PolyTrader.App.Avalonia
|
||||
// Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau.
|
||||
string[] themeKeys =
|
||||
{
|
||||
"AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush", "AppBorderBrush",
|
||||
"AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
|
||||
"AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush",
|
||||
"AppBorderBrush", "AppDividerBrush",
|
||||
"AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
|
||||
"AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush",
|
||||
"AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush",
|
||||
"AppNotifyWarnBrush", "AppNotifyErrorBrush",
|
||||
"AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush",
|
||||
"AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush",
|
||||
"AppChatUserBrush", "AppChatAgentBrush"
|
||||
@@ -298,17 +370,23 @@ namespace PolyTrader.App.Avalonia
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>) und aktiviert
|
||||
/// die at-rest-Verschlüsselung. Muss VOR jeder Credential-Entschlüsselung laufen.
|
||||
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>, sonst wird
|
||||
/// einer erzeugt – D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder
|
||||
/// Credential-Entschlüsselung laufen.
|
||||
/// </summary>
|
||||
private static void ConfigureSecretProtection(TerminalLogger logger)
|
||||
{
|
||||
string? masterKey = Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY");
|
||||
if (string.IsNullOrWhiteSpace(masterKey))
|
||||
{
|
||||
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
|
||||
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
|
||||
}
|
||||
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
|
||||
string? masterKey = MasterKeyResolver.Resolve(
|
||||
keyFile,
|
||||
() => Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"),
|
||||
onGenerated: _ => logger.Warning(
|
||||
"🔐 Kein Master-Key gefunden – ein neuer wurde erzeugt und in \"master.key\" " +
|
||||
"gespeichert (App-Ordner, gitignored). WICHTIG: Diese Datei SOFORT separat sichern " +
|
||||
"(Passwort-Manager/Offline-Backup) – ihr Verlust macht jedes ab jetzt verschlüsselte " +
|
||||
"Secret unwiederbringlich unlesbar."),
|
||||
onWarning: msg => logger.Warning($"⚠️ {msg}"));
|
||||
|
||||
SecretProtection.Configure(masterKey);
|
||||
|
||||
if (SecretProtection.IsConfigured)
|
||||
|
||||
@@ -7,19 +7,29 @@ using PolyTrader.Core.Modularity;
|
||||
namespace PolyTrader.App.Avalonia.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>. Verhält sich wie der frühere
|
||||
/// WinForms-<c>ShellUiHost</c>: je View höchstens ein Fenster, ein bereits offenes wird nach vorn
|
||||
/// geholt, alle Fenster öffnen maximiert.
|
||||
/// Avalonia-Umsetzung von <see cref="IModuleUiHost"/>.
|
||||
///
|
||||
/// <para><b>Umstellung auf die Einfenster-Shell:</b> Früher öffnete jede View ein eigenes
|
||||
/// Fenster. Jetzt gibt es genau ein Anwendungsfenster (<see cref="Views.ShellWindow"/>); eine
|
||||
/// View ist eine <see cref="UserControl"/>-Seite, die in dessen Inhaltsbereich gehängt wird.
|
||||
/// Eine Seite wird beim ersten Aufruf erzeugt und bleibt danach für die Sitzung bestehen –
|
||||
/// das entspricht dem alten „Fenster bleibt offen": Timer, Log-Abos und der Bildlaufstand
|
||||
/// überleben den Seitenwechsel.</para>
|
||||
///
|
||||
/// <para>Der Core-Contract ist toolkit-neutral (<see cref="ModuleView.CreateView"/> liefert
|
||||
/// <see cref="object"/>) – hier wird auf <see cref="Window"/> gecastet. Ein falscher Typ ist ein
|
||||
/// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster erzeugen.</para>
|
||||
/// <see cref="object"/>) – hier wird auf <see cref="Control"/> gecastet. Ein falscher Typ ist ein
|
||||
/// Programmierfehler und soll laut scheitern, nicht still eine leere Seite erzeugen.</para>
|
||||
/// </summary>
|
||||
public sealed class AvaloniaUiHost : IModuleUiHost
|
||||
{
|
||||
/// <summary>ID der Startseite (frühere LauncherForm/LauncherWindow).</summary>
|
||||
public const string StartViewId = "core.start";
|
||||
|
||||
private readonly List<ModuleView> _views = new();
|
||||
private readonly Dictionary<string, Window> _open = new();
|
||||
private Window? _mainWindow;
|
||||
private readonly List<UnavailableView> _unavailable = new();
|
||||
private readonly Dictionary<string, Control> _pages = new();
|
||||
private Views.ShellWindow? _shell;
|
||||
private string? _activeViewId;
|
||||
private bool _shutdownDialogOpen;
|
||||
|
||||
/// <summary>True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde.</summary>
|
||||
@@ -29,20 +39,43 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
|
||||
public IReadOnlyList<ModuleView> Views => _views;
|
||||
|
||||
/// <summary>ID der gerade angezeigten Seite – die Seitenleiste hebt sie hervor.</summary>
|
||||
public string? ActiveViewId => _activeViewId;
|
||||
|
||||
public void RegisterView(ModuleView view) => _views.Add(view);
|
||||
|
||||
/// <summary>Setzt das Hauptfenster (Launcher) – Ziel für <see cref="ActivateMain"/>.</summary>
|
||||
public void SetMainWindow(Window main) => _mainWindow = main;
|
||||
/// <summary>
|
||||
/// Ein bekanntes, in dieser Sitzung aber nicht geladenes Modul. Solche Module haben keine
|
||||
/// Seite – die Seitenleiste zeigt sie trotzdem, ausgegraut und mit dem Grund als Hinweis.
|
||||
/// </summary>
|
||||
/// <param name="Reason">Warum das Modul fehlt (Lizenz, Abschaltung, fehlender API-Key …).</param>
|
||||
public sealed record UnavailableView(string Title, string? IconKey, int Order, string Reason);
|
||||
|
||||
public bool IsOpen(string viewId) => _open.ContainsKey(viewId);
|
||||
/// <summary>
|
||||
/// Meldet ein nicht geladenes Modul an. Ohne das verschwände es spurlos aus der Oberfläche –
|
||||
/// in der Mehrfenster-Fassung fiel das kaum auf, in einer Seitenleiste ist eine unerklärt
|
||||
/// leere Rubrik dagegen schlicht ein Fehler.
|
||||
/// </summary>
|
||||
public void RegisterUnavailable(UnavailableView view) => _unavailable.Add(view);
|
||||
|
||||
public void ActivateMain()
|
||||
{
|
||||
if (_mainWindow == null) return;
|
||||
if (_mainWindow.WindowState == WindowState.Minimized)
|
||||
_mainWindow.WindowState = WindowState.Maximized;
|
||||
_mainWindow.Activate();
|
||||
}
|
||||
/// <summary>Bekannte, aber nicht geladene Module (siehe <see cref="RegisterUnavailable"/>).</summary>
|
||||
public IReadOnlyList<UnavailableView> UnavailableViews => _unavailable;
|
||||
|
||||
/// <summary>Setzt das Shell-Fenster – Ziel aller Seitenwechsel und Besitzer der Dialoge.</summary>
|
||||
public void SetShell(Views.ShellWindow shell) => _shell = shell;
|
||||
|
||||
/// <summary>Das Shell-Fenster, oder <c>null</c> vor dessen Aufbau (z.B. im Smoke-Lauf).</summary>
|
||||
public Window? ShellWindow => _shell;
|
||||
|
||||
/// <summary>
|
||||
/// Wurde die Seite in dieser Sitzung schon einmal aufgerufen? Erbt die Bedeutung des früheren
|
||||
/// „Fenster ist offen": die Seite existiert und arbeitet weiter, auch wenn gerade eine andere
|
||||
/// angezeigt wird.
|
||||
/// </summary>
|
||||
public bool IsOpen(string viewId) => _pages.ContainsKey(viewId);
|
||||
|
||||
/// <summary>Zurück auf die Startseite.</summary>
|
||||
public void ActivateMain() => OpenView(StartViewId);
|
||||
|
||||
public void OpenView(string viewId)
|
||||
{
|
||||
@@ -52,35 +85,20 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
|
||||
public void OpenView(ModuleView view)
|
||||
{
|
||||
if (_open.TryGetValue(view.Id, out var existing))
|
||||
if (!_pages.TryGetValue(view.Id, out var page))
|
||||
{
|
||||
if (existing.WindowState == WindowState.Minimized)
|
||||
existing.WindowState = WindowState.Normal;
|
||||
existing.Activate();
|
||||
return;
|
||||
page = (Control)view.CreateView();
|
||||
_pages[view.Id] = page;
|
||||
}
|
||||
|
||||
var window = (Window)view.CreateView();
|
||||
if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title;
|
||||
|
||||
// Vorgabe aus der WinForms-Oberflaeche uebernommen: alle Fenster maximiert (Full-HD-Ziel).
|
||||
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
window.WindowState = WindowState.Maximized;
|
||||
|
||||
_open[view.Id] = window;
|
||||
window.Closed += (_, _) =>
|
||||
{
|
||||
_open.Remove(view.Id);
|
||||
OpenStateChanged?.Invoke();
|
||||
};
|
||||
|
||||
window.Show();
|
||||
_activeViewId = view.Id;
|
||||
_shell?.ShowPage(view.Title, page);
|
||||
OpenStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt die Sicherheitsabfrage (Beenden-Schaltfläche 10 s gesperrt) und fährt bei Bestätigung
|
||||
/// herunter. Aus jedem Fenster aufrufbar – auch aus Modul-Fenstern, die die App nicht kennen.
|
||||
/// herunter. Aus jeder Seite aufrufbar – auch aus Modul-Seiten, die die App nicht kennen.
|
||||
/// </summary>
|
||||
public async void RequestShutdown()
|
||||
{
|
||||
@@ -89,8 +107,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
try
|
||||
{
|
||||
var dlg = new Views.ShutdownConfirmWindow();
|
||||
bool confirmed = _mainWindow != null
|
||||
? await dlg.ShowDialog<bool>(_mainWindow)
|
||||
bool confirmed = _shell != null
|
||||
? await dlg.ShowDialog<bool>(_shell)
|
||||
: await dlg.ShowDialog<bool>(new Window());
|
||||
if (!confirmed) return;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ using PolyTraderSharp.Services;
|
||||
namespace PolyTrader.App.Avalonia.Shell
|
||||
{
|
||||
/// <summary>
|
||||
/// Registriert die Fenster, die der Core selbst beisteuert (Dashboard, Einstellungen,
|
||||
/// Registriert die Seiten, die der Core selbst beisteuert (Start, Dashboard, Einstellungen,
|
||||
/// Terminal, Jobs). Gegenstück zur entsprechenden Registrierung im WinForms-Program.cs –
|
||||
/// die View-IDs sind bewusst identisch, damit Symbole und Reihenfolge unverändert greifen.
|
||||
/// </summary>
|
||||
@@ -21,10 +21,25 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
|
||||
public static void Register(IModuleUiHost host, IServiceProvider services)
|
||||
{
|
||||
// Die Startseite ist seit der Einfenster-Shell eine View wie jede andere: sie steht in
|
||||
// der Seitenleiste, wird über OpenView aufgerufen und beim Start als erste angezeigt.
|
||||
// Sie heißt „Dashboard", weil sie genau das ist: Kennzahlen, Konten und Live-Überblick.
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = AvaloniaUiHost.StartViewId,
|
||||
Title = "Dashboard",
|
||||
Group = "Core",
|
||||
Order = 0,
|
||||
CreateView = () => new Views.LauncherWindow(host, services)
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.dashboard",
|
||||
Title = "Dashboard",
|
||||
// Umbenannt: „Dashboard" heißt jetzt die Startseite; diese Auswertungsansicht
|
||||
// (KPIs, Diagramme, Tradehistorie) läuft unter „Analytics". Die View-ID bleibt
|
||||
// bewusst unverändert – daran hängen Symbole, Einstellungen und die Gruppierung.
|
||||
Title = "Analytics",
|
||||
Group = "Core",
|
||||
Order = 10,
|
||||
CreateView = () => new Views.DashboardWindow(
|
||||
@@ -47,7 +62,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
services.GetRequiredService<TradingState>(),
|
||||
services.GetRequiredService<TerminalLogger>(),
|
||||
services.GetService<WatchdogHeartbeatService>(),
|
||||
services.GetService<MullvadVpnService>())
|
||||
services.GetService<MullvadVpnService>(),
|
||||
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>())
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core.Modularity;
|
||||
|
||||
@@ -10,15 +11,17 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
/// Begründung in <c>Views/Modules/README.md</c>: die Modulprojekte bleiben dadurch frei von
|
||||
/// Avalonia, was für den kopflosen Linux-Betrieb (<c>--headless</c>) den Ausschlag gibt.
|
||||
///
|
||||
/// <para>Registriert wird nur, was auch geladen ist: ist ein Modul über
|
||||
/// <c>DisabledModules</c> abgeschaltet, fehlen seine Dienste im Container – dann entfällt auch
|
||||
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
|
||||
/// <para>Eine Seite bekommt nur, was auch geladen ist: ist ein Modul über
|
||||
/// <c>DisabledModules</c> abgeschaltet oder fehlt die Lizenz, fehlen seine Dienste im Container.
|
||||
/// Ein solches Modul wird dann als <see cref="AvaloniaUiHost.UnavailableView"/> gemeldet und
|
||||
/// erscheint in der Seitenleiste ausgegraut mit dem Grund – lautlos verschwinden darf es nicht.</para>
|
||||
/// </summary>
|
||||
public static class ModuleViews
|
||||
{
|
||||
public static void Register(IModuleUiHost host, IServiceProvider services)
|
||||
public static void Register(AvaloniaUiHost host, IServiceProvider services)
|
||||
{
|
||||
RegisterIfLoaded(services, "CopyTrading", () => host.RegisterView(new ModuleView
|
||||
RegisterIfLoaded(host, services, "CopyTrading", "Copytrading", "copytrading", 100,
|
||||
() => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "copytrading.main",
|
||||
Title = "Copytrading",
|
||||
@@ -33,7 +36,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
services.GetRequiredService<Modules.CopyTrading.Persistence.ICopyTradeLogRepository>())
|
||||
}));
|
||||
|
||||
RegisterIfLoaded(services, "ResolutionFarming", () => host.RegisterView(new ModuleView
|
||||
RegisterIfLoaded(host, services, "ResolutionFarming", "ResolutionFarming", "resolutionfarming", 200,
|
||||
() => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "resolutionfarming.main",
|
||||
Title = "ResolutionFarming",
|
||||
@@ -48,7 +52,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
services.GetRequiredService<Modules.ResolutionFarming.Persistence.IRfSettingsRepository>())
|
||||
}));
|
||||
|
||||
RegisterIfLoaded(services, "Supervisor", () => host.RegisterView(new ModuleView
|
||||
RegisterIfLoaded(host, services, "Supervisor", "Supervisor", "supervisor", 300,
|
||||
() => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "supervisor.main",
|
||||
Title = "Supervisor",
|
||||
@@ -63,7 +68,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
services.GetRequiredService<PolyTraderSharp.Services.TerminalLogger>())
|
||||
}));
|
||||
|
||||
RegisterIfLoaded(services, "Accounting", () => host.RegisterView(new ModuleView
|
||||
RegisterIfLoaded(host, services, "Accounting", "Accounting", "accounting", 400,
|
||||
() => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "accounting.main",
|
||||
Title = "Accounting",
|
||||
@@ -80,12 +86,58 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
|
||||
private static void RegisterIfLoaded(IServiceProvider services, string moduleName, Action register)
|
||||
/// <summary>
|
||||
/// Registriert die Seite, wenn das Modul in dieser Sitzung geladen ist – sonst einen
|
||||
/// ausgegrauten Platzhalter samt Grund.
|
||||
/// </summary>
|
||||
private static void RegisterIfLoaded(AvaloniaUiHost host, IServiceProvider services,
|
||||
string moduleName, string title, string iconKey, int order, Action register)
|
||||
{
|
||||
bool loaded = services.GetServices<IPolyTraderModule>()
|
||||
.Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase));
|
||||
if (loaded) register();
|
||||
if (loaded)
|
||||
{
|
||||
register();
|
||||
return;
|
||||
}
|
||||
|
||||
host.RegisterUnavailable(new AvaloniaUiHost.UnavailableView(
|
||||
title, iconKey, order, BlockerOf(moduleName, services)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warum fehlt das Modul? Ein modul-eigener Grund (z.B. fehlender API-Key) ist die genaueste
|
||||
/// Auskunft; sonst bleibt der allgemeine Fall: Lizenz oder <c>DisabledModules</c>.
|
||||
/// </summary>
|
||||
private static string BlockerOf(string moduleName, IServiceProvider services)
|
||||
{
|
||||
var config = services.GetService<IConfiguration>();
|
||||
if (config != null)
|
||||
{
|
||||
IPolyTraderModule? known = moduleName.ToLowerInvariant() switch
|
||||
{
|
||||
"copytrading" => new Modules.CopyTrading.CopyTradingModule(),
|
||||
"resolutionfarming" => new Modules.ResolutionFarming.ResolutionFarmingModule(),
|
||||
"supervisor" => new Modules.Supervisor.SupervisorModule(),
|
||||
"accounting" => new Modules.Accounting.AccountingModule(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
string? blocker = known?.GetActivationBlocker(config);
|
||||
if (!string.IsNullOrWhiteSpace(blocker))
|
||||
return $"Modul nicht geladen: {blocker}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Die Auskunft ist ein Komfort, kein Muss – ein Fehler darin darf den Start
|
||||
// nicht aufhalten. Dann greift der allgemeine Text.
|
||||
}
|
||||
}
|
||||
|
||||
return "Modul in dieser Sitzung nicht geladen – Lizenz pruefen oder unter " +
|
||||
"Einstellungen aktivieren (wirkt nach einem Neustart).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PolyTrader.Core.Modularity;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Shell
|
||||
{
|
||||
/// <summary>Schlanker Befehl ohne MVVM-Fremdbibliothek.</summary>
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action _execute;
|
||||
public RelayCommand(Action execute) => _execute = execute;
|
||||
public bool CanExecute(object? parameter) => true;
|
||||
public void Execute(object? parameter) => _execute();
|
||||
public event EventHandler? CanExecuteChanged { add { } remove { } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine große Kachel auf der Startseite. Früher <c>WindowMenuEntry</c> – seit der Umstellung auf
|
||||
/// die Einfenster-Shell öffnet sie keine Fenster mehr, sondern schaltet die Seite um.
|
||||
/// </summary>
|
||||
public sealed class ViewTile
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string? ToolTip { get; init; }
|
||||
public Bitmap? Icon { get; init; }
|
||||
|
||||
/// <summary>Aktuell angezeigte Seite – wird fett dargestellt.</summary>
|
||||
public bool IsCurrent { get; init; }
|
||||
|
||||
/// <summary>Seite wurde in dieser Sitzung schon aufgerufen – bekommt einen Haken.</summary>
|
||||
public bool IsChecked { get; init; }
|
||||
|
||||
public ICommand Command { get; init; } = new RelayCommand(() => { });
|
||||
|
||||
// Von den Bindungen im XAML genutzt (kein Konverter nötig).
|
||||
public string FontWeight => IsCurrent ? "Bold" : "Normal";
|
||||
public string CheckMark => IsChecked ? "✓ " : "";
|
||||
}
|
||||
|
||||
/// <summary>Ein Eintrag der Seitenleiste.</summary>
|
||||
public sealed class ShellNavEntry
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string? ToolTip { get; init; }
|
||||
public Bitmap? Icon { get; init; }
|
||||
|
||||
/// <summary>Diese Seite wird gerade angezeigt – Hinterlegung im Akzent.</summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>false</c> für bekannte, aber nicht geladene Module: der Eintrag steht ausgegraut da
|
||||
/// und nennt im Hinweistext den Grund, statt einfach zu fehlen.
|
||||
/// </summary>
|
||||
public bool IsAvailable { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Beschriftung anzeigen? Steht bewusst am Eintrag und nicht nur am Modell: so kommt die
|
||||
/// Vorlage mit einer geprüften Bindung auf ihren eigenen Datentyp aus und braucht keinen
|
||||
/// Umweg über den DataContext des Fensters (der wäre nur ungeprüft bindbar).
|
||||
/// </summary>
|
||||
public bool LabelsVisible { get; init; } = true;
|
||||
|
||||
/// <summary>Eingeklappte Leiste: nur das Symbol, mittig gesetzt (Klassenbindung im XAML).</summary>
|
||||
public bool IconOnly => !LabelsVisible;
|
||||
|
||||
public ICommand Command { get; init; } = new RelayCommand(() => { });
|
||||
}
|
||||
|
||||
/// <summary>Ein Abschnitt der Seitenleiste („ÜBERSICHT", „MODULE", „SYSTEM").</summary>
|
||||
public sealed class ShellNavGroup
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public bool LabelsVisible { get; init; } = true;
|
||||
public IReadOnlyList<ShellNavEntry> Entries { get; init; } = Array.Empty<ShellNavEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag der Benachrichtigungsleiste über dem Inhalt. Gespeist aus den Warnungen und
|
||||
/// Fehlern des <c>TerminalLogger</c> – dieselbe Quelle wie das Widget „Warnungen & Fehler"
|
||||
/// auf der Startseite, nur live und wegklickbar.
|
||||
/// </summary>
|
||||
public sealed class ShellNotification
|
||||
{
|
||||
public string Source { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
|
||||
/// <summary>Fehler statt Warnung – färbt die Kante rot (Klassenbindung im XAML).</summary>
|
||||
public bool IsError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Setzbar (nicht <c>init</c>), weil der Befehl den Eintrag selbst aus der Liste nimmt und
|
||||
/// ihn dafür kennen muss – er wird direkt nach dem Erzeugen zugewiesen.
|
||||
/// </summary>
|
||||
public ICommand Dismiss { get; set; } = new RelayCommand(() => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Datenmodell der Seitenleiste der Einfenster-Shell. Ersetzt das frühere
|
||||
/// <c>WindowMenuModel</c>, das auf JEDEM Fenster eine Menüleiste aufgespannt hat.
|
||||
///
|
||||
/// <para><b>Layout-Regel:</b> Das Modell liefert nur Daten (<see cref="Groups"/>, Breite,
|
||||
/// Sichtbarkeit der Beschriftungen). Wie ein Eintrag aussieht, steht deklarativ in
|
||||
/// <c>Views/ShellWindow.axaml</c> – hier wird nichts zur Laufzeit zusammengebaut.</para>
|
||||
/// </summary>
|
||||
public sealed class ShellNavModel : INotifyPropertyChanged
|
||||
{
|
||||
/// <summary>Abschnitte der Seitenleiste in Anzeigereihenfolge.</summary>
|
||||
private const string GroupOverview = "ÜBERSICHT";
|
||||
private const string GroupModules = "MODULE";
|
||||
private const string GroupSystem = "SYSTEM";
|
||||
|
||||
private static readonly string[] GroupOrder = { GroupOverview, GroupModules, GroupSystem };
|
||||
|
||||
private readonly AvaloniaUiHost _host;
|
||||
private bool _expanded = true;
|
||||
private string _pageTitle = "";
|
||||
|
||||
public ObservableCollection<ShellNavGroup> Groups { get; } = new();
|
||||
|
||||
public ShellNavModel(AvaloniaUiHost host)
|
||||
{
|
||||
_host = host;
|
||||
Rebuild();
|
||||
_host.OpenStateChanged += OnOpenStateChanged;
|
||||
}
|
||||
|
||||
/// <summary>Registrierung lösen, wenn die Shell geht – kein Event-Leak über die Host-Lebensdauer.</summary>
|
||||
public void Detach() => _host.OpenStateChanged -= OnOpenStateChanged;
|
||||
|
||||
// ===== Seitenleiste ein-/ausklappen =====
|
||||
|
||||
public bool IsExpanded => _expanded;
|
||||
|
||||
/// <summary>Breite der Seitenleiste. Eingeklappt bleiben nur die Symbole stehen.</summary>
|
||||
public double SidebarWidth => _expanded ? 236 : 62;
|
||||
|
||||
/// <summary>Beschriftungen (Marke, Abschnittstitel, Eintragstexte, Fußzeile) nur ausgeklappt.</summary>
|
||||
public bool LabelsVisible => _expanded;
|
||||
|
||||
public string CollapseGlyph => _expanded ? "‹" : "›";
|
||||
public string CollapseToolTip => _expanded ? "Seitenleiste einklappen" : "Seitenleiste ausklappen";
|
||||
|
||||
public void ToggleSidebar()
|
||||
{
|
||||
_expanded = !_expanded;
|
||||
Raise(nameof(IsExpanded));
|
||||
Raise(nameof(SidebarWidth));
|
||||
Raise(nameof(LabelsVisible));
|
||||
Raise(nameof(CollapseGlyph));
|
||||
Raise(nameof(CollapseToolTip));
|
||||
|
||||
// Die Einträge tragen die Sichtbarkeit ihrer Beschriftung selbst (siehe ShellNavEntry) –
|
||||
// deshalb muss die Liste beim Ein-/Ausklappen neu aufgebaut werden.
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
// ===== Kopfzeile =====
|
||||
|
||||
/// <summary>Titel der angezeigten Seite – steht groß in der Kopfzeile.</summary>
|
||||
public string PageTitle
|
||||
{
|
||||
get => _pageTitle;
|
||||
set { _pageTitle = value; Raise(nameof(PageTitle)); }
|
||||
}
|
||||
|
||||
// ===== Navigation =====
|
||||
|
||||
private void OnOpenStateChanged()
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess()) Rebuild();
|
||||
else Dispatcher.UIThread.Post(Rebuild);
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
// Geladene Seiten und nicht geladene Module kommen aus zwei Quellen, sollen in der
|
||||
// Leiste aber gemeinsam nach Order stehen – deshalb erst zu einer Liste zusammenführen.
|
||||
var entries = _host.Views
|
||||
.Select(v => (
|
||||
Group: GroupOf(v.Id),
|
||||
v.Order,
|
||||
v.Title,
|
||||
Entry: new ShellNavEntry
|
||||
{
|
||||
Title = v.Title,
|
||||
ToolTip = v.Title,
|
||||
Icon = ViewIcons.Resolve(v.IconKey),
|
||||
IsActive = v.Id == _host.ActiveViewId,
|
||||
LabelsVisible = _expanded,
|
||||
Command = new RelayCommand(() => _host.OpenView(v.Id))
|
||||
}))
|
||||
.Concat(_host.UnavailableViews.Select(u => (
|
||||
Group: GroupModules,
|
||||
u.Order,
|
||||
u.Title,
|
||||
Entry: new ShellNavEntry
|
||||
{
|
||||
Title = u.Title,
|
||||
ToolTip = u.Reason,
|
||||
Icon = ViewIcons.Resolve(u.IconKey),
|
||||
IsAvailable = false,
|
||||
LabelsVisible = _expanded
|
||||
})))
|
||||
.OrderBy(x => x.Order).ThenBy(x => x.Title)
|
||||
.GroupBy(x => x.Group)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.Entry).ToList());
|
||||
|
||||
Groups.Clear();
|
||||
foreach (var groupTitle in GroupOrder)
|
||||
{
|
||||
if (!entries.TryGetValue(groupTitle, out var groupEntries)) continue;
|
||||
|
||||
Groups.Add(new ShellNavGroup
|
||||
{
|
||||
Title = groupTitle,
|
||||
LabelsVisible = _expanded,
|
||||
Entries = groupEntries
|
||||
});
|
||||
}
|
||||
|
||||
Raise(nameof(Groups));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordnet eine View einem Abschnitt der Seitenleiste zu. Bewusst über die View-ID und nicht
|
||||
/// über <see cref="ModuleView.Group"/>: dort steht der Modulname („CopyTrading"), was in der
|
||||
/// Seitenleiste zu einem Abschnitt je Modul führen würde.
|
||||
/// </summary>
|
||||
private static string GroupOf(string viewId) => viewId switch
|
||||
{
|
||||
AvaloniaUiHost.StartViewId or "core.dashboard" => GroupOverview,
|
||||
"core.jobs" or "core.terminal" or "core.settings" => GroupSystem,
|
||||
_ => GroupModules
|
||||
};
|
||||
|
||||
private void Raise(string name) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
/// <summary>Symbol-Schlüssel → Dateiname in <c>Resources/</c> (als Avalonia-Asset eingebettet).</summary>
|
||||
private static readonly Dictionary<string, string> FileByKey = new()
|
||||
{
|
||||
["dashboard"] = "dashboard.png",
|
||||
["start"] = "dashboard.png",
|
||||
["dashboard"] = "token_quantifier.png",
|
||||
["settings"] = "setting_tools.png",
|
||||
["terminal"] = "error_log.png",
|
||||
["jobs"] = "system_time.png",
|
||||
@@ -30,6 +31,7 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
/// <summary>Standard-Symbolschlüssel je View-ID – identisch zur WinForms-Shell.</summary>
|
||||
private static readonly Dictionary<string, string> DefaultKeyByViewId = new()
|
||||
{
|
||||
[AvaloniaUiHost.StartViewId] = "start",
|
||||
["core.dashboard"] = "dashboard",
|
||||
["core.settings"] = "settings",
|
||||
["core.terminal"] = "terminal",
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PolyTrader.Core.Modularity;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Shell
|
||||
{
|
||||
/// <summary>Schlanker Befehl ohne MVVM-Fremdbibliothek.</summary>
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action _execute;
|
||||
public RelayCommand(Action execute) => _execute = execute;
|
||||
public bool CanExecute(object? parameter) => true;
|
||||
public void Execute(object? parameter) => _execute();
|
||||
public event EventHandler? CanExecuteChanged { add { } remove { } }
|
||||
}
|
||||
|
||||
/// <summary>Ein Eintrag der Fenster-Menüleiste (Launcher, eine View, oder die rechte Aktion).</summary>
|
||||
public sealed class WindowMenuEntry
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string? ToolTip { get; init; }
|
||||
public Bitmap? Icon { get; init; }
|
||||
|
||||
/// <summary>Aktuelles Fenster – wird fett dargestellt.</summary>
|
||||
public bool IsCurrent { get; init; }
|
||||
|
||||
/// <summary>Fenster ist offen (oder das aktuelle) – bekommt einen Haken.</summary>
|
||||
public bool IsChecked { get; init; }
|
||||
|
||||
public ICommand Command { get; init; } = new RelayCommand(() => { });
|
||||
|
||||
// Von den Bindungen im XAML genutzt (kein Konverter nötig).
|
||||
public string FontWeight => IsCurrent ? "Bold" : "Normal";
|
||||
public string CheckMark => IsChecked ? "✓ " : "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Datenmodell der gemeinsamen Fenster-Menüleiste, die auf JEDEM PolyTrader-Fenster erscheint.
|
||||
///
|
||||
/// <para><b>Umsetzung der neuen Layout-Regel:</b> Die WinForms-Fassung baute das Menü zur Laufzeit
|
||||
/// auf (<c>menu.Items.Clear()</c> + dynamisch erzeugte <c>ToolStripMenuItem</c>). Hier liefert das
|
||||
/// Modell nur noch <see cref="Entries"/>; das Layout steht deklarativ in
|
||||
/// <c>Controls/WindowMenuBar.axaml</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class WindowMenuModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly IModuleUiHost _host;
|
||||
private readonly string? _currentViewId;
|
||||
private readonly Action _closeThisWindow;
|
||||
|
||||
public ObservableCollection<WindowMenuEntry> Entries { get; } = new();
|
||||
|
||||
/// <param name="currentViewId">
|
||||
/// <c>null</c> für den Launcher (dort heisst die rechte Aktion „Beenden"), sonst die View-ID
|
||||
/// des Fensters (dann „Fenster schließen").
|
||||
/// </param>
|
||||
public WindowMenuModel(IModuleUiHost host, string? currentViewId, Action closeThisWindow)
|
||||
{
|
||||
_host = host;
|
||||
_currentViewId = currentViewId;
|
||||
_closeThisWindow = closeThisWindow;
|
||||
|
||||
Rebuild();
|
||||
_host.OpenStateChanged += OnOpenStateChanged;
|
||||
ThemeManager.ThemeChanged += OnOpenStateChanged; // Symbol des Umschalters dreht sich
|
||||
}
|
||||
|
||||
/// <summary>Registrierung lösen, wenn das Fenster geht – kein Event-Leak über die Host-Lebensdauer.</summary>
|
||||
public void Detach()
|
||||
{
|
||||
_host.OpenStateChanged -= OnOpenStateChanged;
|
||||
ThemeManager.ThemeChanged -= OnOpenStateChanged;
|
||||
}
|
||||
|
||||
private void OnOpenStateChanged()
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess()) Rebuild();
|
||||
else Dispatcher.UIThread.Post(Rebuild);
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
Entries.Clear();
|
||||
|
||||
Entries.Add(new WindowMenuEntry
|
||||
{
|
||||
Title = "Launcher",
|
||||
IsCurrent = _currentViewId == null,
|
||||
IsChecked = _currentViewId == null,
|
||||
Command = new RelayCommand(_host.ActivateMain)
|
||||
});
|
||||
|
||||
foreach (var view in _host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
string id = view.Id;
|
||||
bool isCurrent = id == _currentViewId;
|
||||
Entries.Add(new WindowMenuEntry
|
||||
{
|
||||
Title = view.Title,
|
||||
Icon = ViewIcons.Resolve(view.IconKey),
|
||||
IsCurrent = isCurrent,
|
||||
IsChecked = isCurrent || _host.IsOpen(id),
|
||||
Command = new RelayCommand(() => _host.OpenView(id))
|
||||
});
|
||||
}
|
||||
|
||||
// Farbschema-Umschalter: sitzt auf JEDEM Fenster, damit er ueberall erreichbar ist.
|
||||
// Sonne = ins Helle wechseln, Mond = ins Dunkle.
|
||||
Entries.Add(new WindowMenuEntry
|
||||
{
|
||||
Title = ThemeManager.IsDark ? "☀" : "🌙",
|
||||
ToolTip = ThemeManager.IsDark ? "Zum hellen Farbschema wechseln" : "Zum dunklen Farbschema wechseln",
|
||||
Command = new RelayCommand(ThemeManager.Toggle)
|
||||
});
|
||||
|
||||
// Kontextabhaengige rechte Aktion – wie in der WinForms-Fassung: nur der Launcher darf die
|
||||
// App beenden (mit Sicherheitsabfrage), jedes andere Fenster schliesst nur sich selbst.
|
||||
Entries.Add(_currentViewId == null
|
||||
? new WindowMenuEntry { Title = "Beenden", Command = new RelayCommand(_host.RequestShutdown) }
|
||||
: new WindowMenuEntry { Title = "Fenster schließen", Command = new RelayCommand(_closeThisWindow) });
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Entries)));
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow"
|
||||
x:DataType="vm:DashboardChartsModel"
|
||||
Title="Dashboard"
|
||||
Width="1714" Height="1083">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow"
|
||||
x:DataType="vm:DashboardChartsModel">
|
||||
|
||||
<!--
|
||||
Modulübergreifendes Dashboard aus dem Core-Trade-Log.
|
||||
@@ -18,8 +15,6 @@
|
||||
damit interaktiv (Tooltips, Zoom) und ohne System.Drawing.
|
||||
-->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Name="btnRefresh" Content="Aktualisieren" />
|
||||
@@ -168,4 +163,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
/// die Diagramme jetzt aus <see cref="DashboardChartsModel"/> (LiveCharts2).
|
||||
/// Layout vollständig in DashboardWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class DashboardWindow : Window
|
||||
public partial class DashboardWindow : UserControl
|
||||
{
|
||||
private readonly DashboardChartsModel _charts = new();
|
||||
private readonly ObservableCollection<DashboardTradeRow> _history = new();
|
||||
@@ -43,8 +43,6 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
public DashboardWindow(IModuleUiHost host, ITradeLogRepository tradeLog, TradingState state,
|
||||
IReadOnlyList<ModuleActivationInfo>? moduleInfos = null, string? settingsPath = null) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.dashboard", this);
|
||||
|
||||
_tradeLog = tradeLog;
|
||||
_state = state;
|
||||
_moduleInfos = moduleInfos;
|
||||
@@ -75,9 +73,10 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
InitModulesTab();
|
||||
|
||||
// Die KPI-Farbe wird im Code gesetzt und wechselt daher nicht von selbst mit dem Thema.
|
||||
void OnTheme() => ApplyScope();
|
||||
Shell.ThemeManager.ThemeChanged += OnTheme;
|
||||
Closed += (_, _) => Shell.ThemeManager.ThemeChanged -= OnTheme;
|
||||
// Kein Abmelden mehr: Seiten werden in der Einfenster-Shell einmal erzeugt und leben
|
||||
// bis zum Programmende (siehe AvaloniaUiHost) – ein Gegenstück zu Window.Closed gibt es
|
||||
// hier nicht, und Unloaded würde bei jedem Seitenwechsel feuern.
|
||||
Shell.ThemeManager.ThemeChanged += ApplyScope;
|
||||
}
|
||||
|
||||
// ===== Daten laden / Scope =====
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Views
|
||||
@@ -7,6 +8,10 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
/// <summary>
|
||||
/// Kleiner Allzweck-Dialog: Hinweis, Ja/Nein-Rückfrage und (maskierte) Eingabe.
|
||||
/// Ersetzt die MessageBox-/Prompt-Aufrufe der WinForms-Oberfläche.
|
||||
///
|
||||
/// <para>Seit der Umstellung auf die Einfenster-Shell rufen die Seiten (UserControls) hier auf
|
||||
/// und können kein <see cref="Window"/> mehr als Besitzer übergeben. Die Helfer nehmen deshalb
|
||||
/// ein beliebiges <see cref="Control"/> und suchen sich das Fenster selbst.</para>
|
||||
/// </summary>
|
||||
public partial class DialogWindow : Window
|
||||
{
|
||||
@@ -35,31 +40,48 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
cancel.Click += (_, _) => Close(false);
|
||||
}
|
||||
|
||||
/// <summary>Reiner Hinweis mit OK.</summary>
|
||||
public static async Task Info(Window owner, string title, string message)
|
||||
/// <summary>
|
||||
/// Fenster zum aufrufenden Steuerelement. Fällt auf das Hauptfenster zurück, falls das
|
||||
/// Steuerelement (noch) nicht im Baum hängt – etwa während der Konstruktionsprüfung.
|
||||
/// </summary>
|
||||
private static Window? OwnerOf(Control owner)
|
||||
{
|
||||
var dlg = new DialogWindow();
|
||||
dlg.Setup(title, message, withInput: false, masked: false, okText: "OK", withCancel: false);
|
||||
await dlg.ShowDialog(owner);
|
||||
if (TopLevel.GetTopLevel(owner) is Window window) return window;
|
||||
return (global::Avalonia.Application.Current?.ApplicationLifetime
|
||||
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
}
|
||||
|
||||
/// <summary>Ja/Nein-Rückfrage. Liefert <c>true</c> bei Bestätigung.</summary>
|
||||
public static async Task<bool> Confirm(Window owner, string title, string message, string okText = "Ja")
|
||||
/// <summary>Reiner Hinweis mit OK.</summary>
|
||||
public static async Task Info(Control owner, string title, string message)
|
||||
{
|
||||
if (OwnerOf(owner) is not { } window) return;
|
||||
var dlg = new DialogWindow();
|
||||
dlg.Setup(title, message, withInput: false, masked: false, okText: "OK", withCancel: false);
|
||||
await dlg.ShowDialog(window);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ja/Nein-Rückfrage. Liefert <c>true</c> bei Bestätigung – und <c>false</c>, wenn sich gar
|
||||
/// kein Fenster finden lässt: ohne Oberfläche gilt nichts als bestätigt.
|
||||
/// </summary>
|
||||
public static async Task<bool> Confirm(Control owner, string title, string message, string okText = "Ja")
|
||||
{
|
||||
if (OwnerOf(owner) is not { } window) return false;
|
||||
var dlg = new DialogWindow();
|
||||
dlg.Setup(title, message, withInput: false, masked: false, okText: okText, withCancel: true);
|
||||
return await dlg.ShowDialog<bool>(owner);
|
||||
return await dlg.ShowDialog<bool>(window);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eingabe. Liefert <c>null</c> bei Abbruch – leerer String bedeutet bewusst „Wert entfernen"
|
||||
/// (dieselbe Unterscheidung wie in der bisherigen Oberfläche).
|
||||
/// </summary>
|
||||
public static async Task<string?> Prompt(Window owner, string title, string message, bool masked = true)
|
||||
public static async Task<string?> Prompt(Control owner, string title, string message, bool masked = true)
|
||||
{
|
||||
if (OwnerOf(owner) is not { } window) return null;
|
||||
var dlg = new DialogWindow();
|
||||
dlg.Setup(title, message, withInput: true, masked: masked, okText: "OK", withCancel: true);
|
||||
bool ok = await dlg.ShowDialog<bool>(owner);
|
||||
bool ok = await dlg.ShowDialog<bool>(window);
|
||||
return ok ? dlg.FindControl<TextBox>("txtInput")!.Text ?? string.Empty : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:models="using:PolyTraderSharp.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.JobsWindow"
|
||||
Title="Server Jobs"
|
||||
Width="1429" Height="833">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:models="using:PolyTraderSharp.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.JobsWindow">
|
||||
|
||||
<!--
|
||||
Ansicht der Hintergrund-Jobs (JobManager).
|
||||
@@ -14,8 +11,6 @@
|
||||
auf anderen Auflösungen mitwächst — die Proportionen bleiben erhalten.
|
||||
-->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Name="btnRefresh" Content="Aktualisieren" />
|
||||
@@ -51,4 +46,4 @@
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -12,14 +12,16 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
/// Hintergrund-Jobs (JobManager). Verhalten wie bisher: „Run Now" löst den ManualTrigger aus,
|
||||
/// „Aktiv" schaltet den Job über die Bindung. Layout vollständig in JobsWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class JobsWindow : Window
|
||||
public partial class JobsWindow : UserControl
|
||||
{
|
||||
public JobsWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <param name="host">
|
||||
/// Wird für diese Seite nicht mehr gebraucht (die Navigation sitzt in der Shell), bleibt
|
||||
/// aber im Vertrag, weil <c>ModuleView.CreateView</c> alle Seiten gleich erzeugt.
|
||||
/// </param>
|
||||
public JobsWindow(IModuleUiHost host, JobManager jobManager) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.jobs", this);
|
||||
|
||||
var grid = this.FindControl<DataGrid>("gridJobs")!;
|
||||
grid.ItemsSource = jobManager.Jobs;
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.LauncherWindow"
|
||||
Title="PolyTrader"
|
||||
Width="1600" Height="900"
|
||||
WindowState="Maximized">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.LauncherWindow">
|
||||
|
||||
<!--
|
||||
„Startleiste" der Anwendung. Entspricht der bisherigen LauncherForm:
|
||||
oben die gemeinsame Fenster-Menüleiste, darunter die Schnellaktionen (Trading-Modi),
|
||||
dann die Fenster-Buttons, Account-Übersicht, Modul-KPIs und Live-Überblick-Widgets, unten die Statusleiste.
|
||||
Startseite der Anwendung. Entspricht der bisherigen LauncherForm, ist seit der Umstellung
|
||||
auf die Einfenster-Shell aber eine Seite unter vielen: oben die Schnellaktionen
|
||||
(Trading-Modi), dann die Kacheln zu den übrigen Ansichten, Account-Übersicht, Modul-KPIs
|
||||
und die Live-Überblick-Widgets.
|
||||
|
||||
Die frühere Statusleiste (Trading / Module / API) steht jetzt einmal zentral in
|
||||
Views/ShellWindow.axaml und nicht mehr auf dieser Seite.
|
||||
-->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Button Name="btnLiveTrading" Content="LiveTrading (—)" MinWidth="170" />
|
||||
@@ -23,17 +21,25 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<StackPanel Orientation="Horizontal" Spacing="24">
|
||||
<TextBlock Name="lblTrading" Text="Trading: —" />
|
||||
<TextBlock Name="lblModules" Text="Module: —" />
|
||||
<TextBlock Name="lblRatelimit" Text="API: —" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
D-4: Update-Hinweis. Verborgen, solange keine Prüfung lief oder keine neue Version
|
||||
vorliegt - erscheint nur, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist UND
|
||||
tatsächlich ein Update gefunden wurde. Installiert NIE automatisch (Leitentscheidung 4).
|
||||
-->
|
||||
<Border Name="pnlUpdateBanner" DockPanel.Dock="Top" IsVisible="False"
|
||||
Background="{DynamicResource AppWarningBrush}" Padding="10,6">
|
||||
<DockPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" DockPanel.Dock="Right">
|
||||
<Button Name="btnInstallUpdate" Content="Jetzt installieren …" />
|
||||
<Button Name="btnDismissUpdate" Content="Später" />
|
||||
</StackPanel>
|
||||
<TextBlock Name="lblUpdateInfo" Text="—" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="12" Spacing="16">
|
||||
<TextBlock Text="Fenster" FontSize="14" FontWeight="SemiBold" />
|
||||
<StackPanel Margin="0,14,0,0" Spacing="16">
|
||||
<TextBlock Text="ANSICHTEN" Classes="kicker" />
|
||||
|
||||
<ItemsControl Name="windowButtons">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -42,7 +48,7 @@
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="shell:WindowMenuEntry">
|
||||
<DataTemplate x:DataType="shell:ViewTile">
|
||||
<Button Command="{Binding Command}"
|
||||
MinWidth="190" MinHeight="56"
|
||||
Margin="0,0,8,8"
|
||||
@@ -156,4 +162,4 @@
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -3,17 +3,22 @@ using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.App.Avalonia.Shell;
|
||||
using PolyTrader.App.Avalonia.ViewModels;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Analytics;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTrader.Modules.Supervisor.Persistence;
|
||||
using PolyTraderSharp;
|
||||
using PolyTraderSharp.Models;
|
||||
@@ -22,17 +27,23 @@ using PolyTraderSharp.Services;
|
||||
namespace PolyTrader.App.Avalonia.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// „Startleiste" der Anwendung – Gegenstück zur bisherigen LauncherForm. Öffnet die Fenster,
|
||||
/// spiegelt deren Offen-Status, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets.
|
||||
/// Startseite der Anwendung – Gegenstück zur bisherigen LauncherForm. Führt zu den übrigen
|
||||
/// Ansichten, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets.
|
||||
/// Layout vollständig in LauncherWindow.axaml.
|
||||
///
|
||||
/// <para>Seit der Umstellung auf die Einfenster-Shell ist dies ein <see cref="UserControl"/>
|
||||
/// und kein Fenster mehr; der Klassenname bleibt vorerst, um die parallele
|
||||
/// Deploymentcenter-Arbeit an dieser Datei nicht mit einer Umbenennung zu kollidieren.
|
||||
/// Die Statusleiste (Trading/Module/API) und das Beenden liegen jetzt in
|
||||
/// <see cref="ShellWindow"/>.</para>
|
||||
/// </summary>
|
||||
public partial class LauncherWindow : Window
|
||||
public partial class LauncherWindow : UserControl
|
||||
{
|
||||
private readonly AvaloniaUiHost _uiHost;
|
||||
private readonly IModuleUiHost _uiHost;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly TradingState _state;
|
||||
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly ObservableCollection<WindowMenuEntry> _windowButtons = new();
|
||||
private readonly ObservableCollection<ViewTile> _windowButtons = new();
|
||||
private readonly ObservableCollection<AccountOverviewRow> _accountRows = new();
|
||||
private readonly ObservableCollection<ModuleKpiTile> _kpiTiles = new();
|
||||
private readonly ObservableCollection<NotableTradeRow> _notableRows = new();
|
||||
@@ -40,17 +51,17 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
|
||||
private int _statusTicks;
|
||||
|
||||
/// <summary>Ergebnis der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
|
||||
private UpdateCheckResult? _updateCheck;
|
||||
|
||||
public LauncherWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider services) : this()
|
||||
public LauncherWindow(IModuleUiHost uiHost, IServiceProvider services) : this()
|
||||
{
|
||||
_uiHost = uiHost;
|
||||
_services = services;
|
||||
_state = services.GetRequiredService<TradingState>();
|
||||
|
||||
// currentViewId == null => dies ist der Launcher (rechte Menueaktion "Beenden").
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(uiHost, null, this);
|
||||
|
||||
this.FindControl<ItemsControl>("windowButtons")!.ItemsSource = _windowButtons;
|
||||
this.FindControl<DataGrid>("gridAccounts")!.ItemsSource = _accountRows;
|
||||
this.FindControl<ItemsControl>("kpiTiles")!.ItemsSource = _kpiTiles;
|
||||
@@ -71,27 +82,24 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
UpdateTradingToggles();
|
||||
};
|
||||
|
||||
this.FindControl<Button>("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
|
||||
this.FindControl<Button>("btnDismissUpdate")!.Click += (_, _) =>
|
||||
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = false;
|
||||
|
||||
_statusTimer.Tick += (_, _) => UpdateStatus();
|
||||
_statusTimer.Start();
|
||||
UpdateStatus();
|
||||
RefreshData();
|
||||
|
||||
// Schliessen des Launchers laeuft ueber dieselbe Sicherheitsabfrage wie "Beenden".
|
||||
Closing += (_, e) =>
|
||||
{
|
||||
if (_uiHost.ShutdownConfirmed) return;
|
||||
e.Cancel = true;
|
||||
Dispatcher.UIThread.Post(_uiHost.RequestShutdown);
|
||||
};
|
||||
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
|
||||
ThemeManager.ThemeChanged += UpdateTradingToggles;
|
||||
// D-4: einmalige, stille Prüfung beim Start - fire-and-forget, darf den Start nie
|
||||
// verzögern oder blockieren. Nur aktiv, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist.
|
||||
_ = CheckForUpdateAsync();
|
||||
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
_statusTimer.Stop();
|
||||
_uiHost.OpenStateChanged -= OnOpenStateChanged;
|
||||
ThemeManager.ThemeChanged -= UpdateTradingToggles;
|
||||
};
|
||||
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
|
||||
// Kein Abmelden: die Seite lebt in der Einfenster-Shell bis zum Programmende, ein
|
||||
// Gegenstueck zu Window.Closed gibt es nicht (Sicherheitsabfrage und Beenden liegen
|
||||
// jetzt im ShellWindow).
|
||||
ThemeManager.ThemeChanged += UpdateTradingToggles;
|
||||
}
|
||||
|
||||
/// <summary>Dreistufiger Wechsel wie bisher: Inactive -> SellOnly -> Active -> Inactive.</summary>
|
||||
@@ -108,14 +116,20 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
else Dispatcher.UIThread.Post(RebuildWindowButtons);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kacheln zu den übrigen Ansichten. Die Startseite selbst bleibt aussen vor – sie ist ja
|
||||
/// gerade zu sehen; ihre Kachel würde nur ins Leere führen und dauerhaft als „offen" gelten.
|
||||
/// </summary>
|
||||
private void RebuildWindowButtons()
|
||||
{
|
||||
_windowButtons.Clear();
|
||||
foreach (var view in _uiHost.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
foreach (var view in _uiHost.Views
|
||||
.Where(v => v.Id != AvaloniaUiHost.StartViewId)
|
||||
.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
string id = view.Id;
|
||||
bool open = _uiHost.IsOpen(id);
|
||||
_windowButtons.Add(new WindowMenuEntry
|
||||
_windowButtons.Add(new ViewTile
|
||||
{
|
||||
Title = view.Title,
|
||||
Icon = ViewIcons.Resolve(view.IconKey),
|
||||
@@ -144,19 +158,12 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hält die Umschalter und die Widgets frisch. Die frühere Statusleiste dieser Seite ist
|
||||
/// in die Shell gewandert und wird dort im eigenen Takt aktualisiert.
|
||||
/// </summary>
|
||||
private void UpdateStatus()
|
||||
{
|
||||
string trading = _state.GlobalTradingPaused
|
||||
? "Pausiert"
|
||||
: $"Live={_state.LiveTradingMode} / Demo={_state.DemoTradingMode}";
|
||||
this.FindControl<TextBlock>("lblTrading")!.Text = $"Trading: {trading}";
|
||||
|
||||
int moduleCount = _services.GetServices<IPolyTraderModule>().Count();
|
||||
this.FindControl<TextBlock>("lblModules")!.Text = $"Module: {moduleCount}";
|
||||
|
||||
this.FindControl<TextBlock>("lblRatelimit")!.Text =
|
||||
$"API: {(_state.IsAlchemyHealthy ? "WSS aktiv" : "Polling")}";
|
||||
|
||||
UpdateTradingToggles();
|
||||
|
||||
// Account-Übersicht + Widgets alle 30 s aktualisieren
|
||||
@@ -175,6 +182,110 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
UpdateSupervisor();
|
||||
}
|
||||
|
||||
// ===== Update-Prüfung (D-4) =====
|
||||
|
||||
/// <summary>
|
||||
/// Prüft beim Start still auf ein neues Release. Installiert NIE automatisch (Leitentscheidung
|
||||
/// 4 aus dem Umsetzungsplan) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das
|
||||
/// Verhalten. Wirft nie: ein nicht erreichbares Deploymentcenter darf den Start nicht stören.
|
||||
/// </summary>
|
||||
private async Task CheckForUpdateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dcOptions = _services.GetService<DeploymentcenterOptions>();
|
||||
if (dcOptions == null || !dcOptions.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dcOptions.BaseUrl))
|
||||
return;
|
||||
|
||||
var settings = _services.GetService<ServerSettings>();
|
||||
string? licenseKey = null;
|
||||
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
|
||||
catch { /* nicht lesbarer Schlüssel -> Prüfung läuft ohne Zugangsdaten weiter */ }
|
||||
|
||||
var updateClient = new UpdateClient();
|
||||
var result = await updateClient.CheckForUpdateAsync(
|
||||
baseUrl: dcOptions.BaseUrl,
|
||||
projectId: dcOptions.ProjectSlug,
|
||||
currentVersion: BuildInfo.Version,
|
||||
channel: dcOptions.Channel,
|
||||
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
|
||||
|
||||
if (result.Unauthorized)
|
||||
{
|
||||
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr für den Paketabruf. Kein
|
||||
// Alarm im Launcher nötig - die Lizenzprüfung selbst (D-2) hat das schon gemeldet.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.UpdateAvailable || result.LatestRelease == null) return;
|
||||
|
||||
_updateCheck = result;
|
||||
string version = result.LatestRelease.Version;
|
||||
string critical = result.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
|
||||
$"Update verfügbar: v{version}{critical}. {result.LatestRelease.Changelog}".TrimEnd();
|
||||
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = true;
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Update-Prüfung darf den Start nie stören - stiller Rückzug, kein Log-Spam beim
|
||||
// vermutlich häufigsten Fall (Deploymentcenter nicht erreichbar).
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet den Update-Agent nach Bestätigung. Läuft über denselben geordneten Shutdown-Weg
|
||||
/// wie „Beenden" (inklusive der 10-Sekunden-Sicherheitsabfrage) - <c>exitCurrentApp: false</c>
|
||||
/// ist hier Pflicht, kein Environment.Exit bei offenen Positionen (Leitentscheidung 3).
|
||||
/// </summary>
|
||||
private async Task InstallUpdateAsync()
|
||||
{
|
||||
if (_updateCheck?.LatestRelease == null) return;
|
||||
|
||||
var dcOptions = _services.GetService<DeploymentcenterOptions>();
|
||||
if (dcOptions == null) return;
|
||||
|
||||
string? agentPath = UpdateClient.ResolveAgentPath();
|
||||
if (agentPath == null)
|
||||
{
|
||||
await DialogWindow.Info(this, "Update",
|
||||
"Kein Update-Agent im Installationsverzeichnis gefunden. Diese Installation kann " +
|
||||
"sich nicht selbst aktualisieren - bitte das neue Paket von Hand einspielen.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool confirmed = await DialogWindow.Confirm(this, "Update installieren",
|
||||
$"Version {_updateCheck.LatestRelease.Version} herunterladen und PolyTrader danach " +
|
||||
"neu starten?\n\nOffene Vorgänge werden dabei genauso geordnet beendet wie über „Beenden\".",
|
||||
"Installieren");
|
||||
if (!confirmed) return;
|
||||
|
||||
var settings = _services.GetService<ServerSettings>();
|
||||
string? licenseKey = null;
|
||||
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
|
||||
catch { /* siehe CheckForUpdateAsync */ }
|
||||
|
||||
UpdateClient.LaunchUpdateAgent(
|
||||
agentPath: agentPath,
|
||||
projectId: dcOptions.ProjectSlug,
|
||||
channel: dcOptions.Channel,
|
||||
action: "update",
|
||||
version: "latest",
|
||||
currentVersion: BuildInfo.Version,
|
||||
exitCurrentApp: false,
|
||||
waitForCurrentProcess: true,
|
||||
waitTimeoutSeconds: 40,
|
||||
licenseKey: licenseKey);
|
||||
|
||||
// Derselbe Weg wie ein Klick auf "Beenden" - inklusive Sicherheitsabfrage. Der Agent
|
||||
// wartet ohnehin per --wait-for-pid auf unser Prozessende.
|
||||
_uiHost.RequestShutdown();
|
||||
}
|
||||
|
||||
// ===== Account-Übersicht =====
|
||||
|
||||
private void LoadAccountOverview()
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:acc="using:PolyTrader.Modules.Accounting.Models"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow"
|
||||
Title="Accounting"
|
||||
Width="1571" Height="1067">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:acc="using:PolyTrader.Modules.Accounting.Models"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.AccountingWindow">
|
||||
|
||||
<!-- Drei Tabs (Übersicht/BWA, Ledger, Abruf/Status), Aufbau nach
|
||||
docs/UI-SPEZIFIKATION-WinForms.md. KPI-Kacheln zweizeilig wie zuvor. -->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Name="lblStatus" Text="Bereit." />
|
||||
</Border>
|
||||
@@ -164,4 +159,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
/// Accounting: Periodenabrechnung mit KPI-Kacheln und Monatsübersicht, Ledger-Ansicht und
|
||||
/// Abruf-/Status-Tab. Layout vollständig in AccountingWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class AccountingWindow : Window
|
||||
public partial class AccountingWindow : UserControl
|
||||
{
|
||||
private readonly ObservableCollection<MonthlyRow> _monthly = new();
|
||||
private readonly ObservableCollection<LedgerEntry> _ledger = new();
|
||||
@@ -43,8 +43,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
ILedgerRepository ledgerRepo, IIngestRunRepository runRepo,
|
||||
AccountingIngestService ingest, TerminalLogger logger) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "accounting.main", this);
|
||||
|
||||
_state = state;
|
||||
_reports = reports;
|
||||
_ledgerRepo = ledgerRepo;
|
||||
@@ -157,7 +155,12 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
|
||||
try
|
||||
{
|
||||
var picker = await StorageProvider.SaveFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerSaveOptions
|
||||
// Seit der Umstellung auf Seiten ist „this" kein TopLevel mehr – der Dateidialog
|
||||
// gehört dem Shell-Fenster und wird über den Baum aufgelöst.
|
||||
var storage = TopLevel.GetTopLevel(this)?.StorageProvider;
|
||||
if (storage == null) { Status("Kein Fenster für den Dateidialog verfügbar."); return; }
|
||||
|
||||
var picker = await storage.SaveFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerSaveOptions
|
||||
{
|
||||
Title = pdf ? "PDF-Export" : "CSV-Export",
|
||||
SuggestedFileName = $"abrechnung_{_statement.From:yyyyMMdd}_{_statement.To:yyyyMMdd}" + (pdf ? ".pdf" : ".csv"),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
xmlns:ct="using:PolyTraderSharp.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow"
|
||||
Title="Copytrading"
|
||||
Width="1714" Height="1167">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
xmlns:ct="using:PolyTraderSharp.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.CopyTradingWindow">
|
||||
|
||||
<!--
|
||||
Ein Fenster fürs ganze Modul, vier Tabs – Aufbau nach docs/UI-SPEZIFIKATION-WinForms.md.
|
||||
@@ -13,8 +11,6 @@
|
||||
Pixelbreiten sind in Sternbreiten übersetzt, damit das Fenster mitwächst.
|
||||
-->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<TabControl>
|
||||
|
||||
<TabItem Header="Master-Trader">
|
||||
@@ -160,4 +156,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
/// Copytrading-Fenster mit vier Tabs: Master-Trader, Offene Trades, Geschlossene Trades,
|
||||
/// Account-Einstellungen. Layout vollständig in CopyTradingWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class CopyTradingWindow : Window
|
||||
public partial class CopyTradingWindow : UserControl
|
||||
{
|
||||
private readonly ObservableCollection<TrackedTrader> _traders = new();
|
||||
private readonly ObservableCollection<AccountAssignment> _assignments = new();
|
||||
@@ -39,8 +39,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
ITrackedTraderRepository traderRepo, ICopyTradingAccountSettingsRepository settingsRepo,
|
||||
ICopyTradeLogRepository tradeLog) : this()
|
||||
{
|
||||
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "copytrading.main", this);
|
||||
|
||||
_state = state;
|
||||
_copyState = copyState;
|
||||
_traderRepo = traderRepo;
|
||||
@@ -103,8 +101,9 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
gridOpen.ItemsSource = null; gridOpen.ItemsSource = _openTrades;
|
||||
gridClosed.ItemsSource = null; gridClosed.ItemsSource = _closedTrades;
|
||||
}
|
||||
// Kein Abmelden: Seiten leben in der Einfenster-Shell bis zum Programmende
|
||||
// (siehe AvaloniaUiHost), ein Gegenstueck zu Window.Closed gibt es nicht.
|
||||
Shell.ThemeManager.ThemeChanged += OnTheme;
|
||||
Closed += (_, _) => Shell.ThemeManager.ThemeChanged -= OnTheme;
|
||||
}
|
||||
|
||||
// ===== Master-Trader =====
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow"
|
||||
Title="ResolutionFarming"
|
||||
Width="1429" Height="1030">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:rf="using:PolyTrader.Modules.ResolutionFarming.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.ResolutionFarmingWindow">
|
||||
|
||||
<!-- Vier Tabs (Kandidaten / Positionen / Historie+Statistik / Settings), Aufbau nach
|
||||
docs/UI-SPEZIFIKATION-WinForms.md: je Tab ein Grid mit „Aktualisieren", Konto-Auswahl oben. -->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="Konto:" VerticalAlignment="Center" />
|
||||
@@ -94,4 +90,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
/// ResolutionFarming: Kandidaten, offene Positionen, Historie und Einstellungen je Konto.
|
||||
/// Layout vollständig in ResolutionFarmingWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class ResolutionFarmingWindow : Window
|
||||
public partial class ResolutionFarmingWindow : UserControl
|
||||
{
|
||||
private readonly ObservableCollection<RfCandidate> _candidates = new();
|
||||
private readonly ObservableCollection<RfPosition> _positions = new();
|
||||
@@ -35,8 +35,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
IRfCandidateRepository candidateRepo, IRfPositionRepository positionRepo,
|
||||
IRfClosedTradeRepository closedRepo, IRfSettingsRepository settingsRepo) : this()
|
||||
{
|
||||
this.FindControl<WindowMenuBar>("menuBar")!.Attach(host, "resolutionfarming.main", this);
|
||||
|
||||
_state = state;
|
||||
_candidateRepo = candidateRepo;
|
||||
_positionRepo = positionRepo;
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence"
|
||||
xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual"
|
||||
xmlns:svc="using:PolyTrader.Modules.Supervisor.Services"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow"
|
||||
Title="Supervisor"
|
||||
Width="1786" Height="1163">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
xmlns:sup="using:PolyTrader.Modules.Supervisor.Persistence"
|
||||
xmlns:cf="using:PolyTrader.Modules.Supervisor.Counterfactual"
|
||||
xmlns:svc="using:PolyTrader.Modules.Supervisor.Services"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.Modules.SupervisorWindow">
|
||||
|
||||
<!-- Vier Tabs (Analyse / Dossiers / Berichte / Counterfactual), Aufbau nach
|
||||
docs/UI-SPEZIFIKATION-WinForms.md. Der Analyse-Tab ist chat-artig: Verlauf oben,
|
||||
Eingabe unten, Profil- und Modellauswahl in der Werkzeugleiste. -->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<TabControl>
|
||||
|
||||
<TabItem Header="Analyse">
|
||||
@@ -126,4 +121,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
/// Supervisor: Analyse-Chat gegen den Agenten, Dossiers je Signal, gespeicherte Berichte und
|
||||
/// Counterfactuals. Layout vollständig in SupervisorWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class SupervisorWindow : Window
|
||||
public partial class SupervisorWindow : UserControl
|
||||
{
|
||||
private readonly ObservableCollection<ChatEntry> _chat = new();
|
||||
private readonly ObservableCollection<SignalSummary> _signals = new();
|
||||
@@ -38,8 +38,6 @@ namespace PolyTrader.App.Avalonia.Views.Modules
|
||||
ISupervisorReportRepository reportRepo, ISupervisorCounterfactualRepository cfRepo,
|
||||
TerminalLogger logger) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "supervisor.main", this);
|
||||
|
||||
_agent = agent;
|
||||
_dossiers = dossiers;
|
||||
_reportRepo = reportRepo;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:models="using:PolyTraderSharp.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.SettingsWindow"
|
||||
Title="Server Settings"
|
||||
Width="1185" Height="820">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:models="using:PolyTraderSharp.Models"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.SettingsWindow">
|
||||
|
||||
<!--
|
||||
Einstellungen. Ersetzt die beiden PropertyGrids der WinForms-Fassung durch den
|
||||
@@ -12,8 +10,6 @@
|
||||
Erklärung klein darunter).
|
||||
-->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<TextBlock Name="lblStatus" Text="Bereit." />
|
||||
</Border>
|
||||
@@ -35,6 +31,11 @@
|
||||
ToolTip.Tip="Speichert den Watchdog-Agent-Token (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt)." />
|
||||
<Button Name="btnTestWatchdog" Content="Test-Heartbeat senden"
|
||||
ToolTip.Tip="Sendet sofort einen Heartbeat an den konfigurierten Watchdog-Server." />
|
||||
<Separator Margin="6,0" />
|
||||
<Button Name="btnLicenseKey" Content="Lizenzschlüssel setzen …"
|
||||
ToolTip.Tip="Speichert den Deploymentcenter-Lizenzschlüssel (maskierte Eingabe, bei gesetztem Master-Key verschlüsselt)." />
|
||||
<Button Name="btnCheckLicense" Content="Lizenz jetzt prüfen"
|
||||
ToolTip.Tip="Prüft den gespeicherten Lizenzschlüssel gegen das Deploymentcenter und zeigt Status sowie Hardware-ID." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -72,4 +73,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -2,11 +2,13 @@ using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Deploymentcenter.Client;
|
||||
using PolyTrader.App.Avalonia.Controls;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Core.Security;
|
||||
@@ -21,7 +23,7 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
/// den <see cref="SettingsEditor"/> anstelle der früheren PropertyGrids.
|
||||
/// Layout vollständig in SettingsWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class SettingsWindow : Window
|
||||
public partial class SettingsWindow : UserControl
|
||||
{
|
||||
private const string SettingsPath = "server_settings.xml";
|
||||
|
||||
@@ -33,20 +35,20 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
private WatchdogHeartbeatService? _watchdog;
|
||||
private MullvadVpnService? _vpn;
|
||||
private TerminalLogger? _logger;
|
||||
private DeploymentcenterOptions? _dcOptions;
|
||||
|
||||
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
|
||||
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
|
||||
MullvadVpnService? vpn = null) : this()
|
||||
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.settings", this);
|
||||
|
||||
_accountRepo = accountRepo;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
_watchdog = watchdog;
|
||||
_vpn = vpn;
|
||||
_dcOptions = dcOptions;
|
||||
|
||||
var grid = this.FindControl<DataGrid>("gridAccounts")!;
|
||||
grid.ItemsSource = _accounts;
|
||||
@@ -59,6 +61,8 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
|
||||
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
|
||||
this.FindControl<Button>("btnTestWatchdog")!.Click += async (_, _) => await TestWatchdogAsync();
|
||||
this.FindControl<Button>("btnLicenseKey")!.Click += async (_, _) => await SetLicenseKeyAsync();
|
||||
this.FindControl<Button>("btnCheckLicense")!.Click += async (_, _) => await CheckLicenseAsync();
|
||||
|
||||
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
|
||||
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
|
||||
@@ -199,19 +203,8 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
/// mit der üblichen umask sonst <c>644</c> an – die Datei wäre für alle lesbar. Unter Windows
|
||||
/// greift die NTFS-Vererbung, dort ist nichts zu tun.
|
||||
/// </summary>
|
||||
private void RestrictToOwner(string path)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Warning($"⚠️ Dateirechte für {Path.GetFileName(path)} konnten nicht gesetzt werden: {ex.Message}. " +
|
||||
"Bitte manuell prüfen (chmod 600).");
|
||||
}
|
||||
}
|
||||
private void RestrictToOwner(string path) =>
|
||||
FilePermissions.RestrictToOwner(path, msg => _logger?.Warning($"⚠️ {msg}"));
|
||||
|
||||
// ===== OpenRouter-API-Key (Supervisor) =====
|
||||
|
||||
@@ -323,5 +316,111 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
btn.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Lizenz (D-2) =====
|
||||
|
||||
private async System.Threading.Tasks.Task SetLicenseKeyAsync()
|
||||
{
|
||||
bool exists = !string.IsNullOrWhiteSpace(_settings.LicenseKey);
|
||||
string? key = await DialogWindow.Prompt(this, "Deploymentcenter-Lizenzschlüssel",
|
||||
"Lizenzschlüssel eingeben.\n" +
|
||||
"Wird in der gitignorierten server_settings.xml gespeichert (leer = entfernen).\n" +
|
||||
"Wirkt erst beim nächsten Start von PolyTrader (keine Laufzeit-Revalidierung).\n" +
|
||||
(exists ? "Aktuell ist ein Schlüssel gesetzt." : "Aktuell ist KEIN Schlüssel gesetzt."));
|
||||
if (key == null) return; // Abbruch
|
||||
|
||||
try
|
||||
{
|
||||
_settings.LicenseKey = string.IsNullOrWhiteSpace(key)
|
||||
? string.Empty
|
||||
: SecretProtection.Protect(key.Trim());
|
||||
_settings.Save(SettingsPath);
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
|
||||
if (_settings.LicenseKey.Length == 0)
|
||||
{
|
||||
_logger?.Info("Lizenzschlüssel entfernt.");
|
||||
Status("Lizenzschlüssel entfernt – PolyTrader startet beim nächsten Mal im eingeschränkten Modus.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool encrypted = SecretProtection.IsEncrypted(_settings.LicenseKey);
|
||||
_logger?.Info($"Lizenzschlüssel gespeichert ({(encrypted ? "verschlüsselt" : "Klartext")}).");
|
||||
Status((encrypted
|
||||
? "Lizenzschlüssel gespeichert (verschlüsselt). "
|
||||
: "Lizenzschlüssel gespeichert – ACHTUNG: ohne Master-Key im Klartext. ") +
|
||||
"Mit „Lizenz jetzt prüfen\" testen, wirkt für Module erst beim nächsten Start.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DialogWindow.Info(this, "Fehler", $"Fehler beim Speichern des Lizenzschlüssels: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft den GESPEICHERTEN Schlüssel gegen das Deploymentcenter und zeigt Urteil und
|
||||
/// Hardware-ID (zum Kopieren, falls Richard sie für eine Aktivierung im Admin braucht).
|
||||
/// Ungespeicherte Änderungen wirken bewusst nicht, aus demselben Grund wie beim Watchdog-Test.
|
||||
/// </summary>
|
||||
private async System.Threading.Tasks.Task CheckLicenseAsync()
|
||||
{
|
||||
if (_dcOptions == null) return;
|
||||
var btn = this.FindControl<Button>("btnCheckLicense")!;
|
||||
btn.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_dcOptions.BaseUrl))
|
||||
{
|
||||
await DialogWindow.Info(this, "Lizenz", "Keine Deploymentcenter-URL konfiguriert (appsettings.json).");
|
||||
return;
|
||||
}
|
||||
|
||||
string key;
|
||||
try { key = SecretProtection.Unprotect(_settings.LicenseKey); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DialogWindow.Info(this, "Lizenz", $"Gespeicherter Schlüssel nicht lesbar: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
var hw = HardwareId.GetHardwareId(_dcOptions.ProjectSlug);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
await DialogWindow.Info(this, "Lizenz",
|
||||
$"Kein Lizenzschlüssel gesetzt.\n\nHardware-ID dieser Maschine:\n{hw.HardwareId}");
|
||||
Status("Kein Lizenzschlüssel gesetzt.");
|
||||
return;
|
||||
}
|
||||
|
||||
Status("Prüfe Lizenz …");
|
||||
LicenseClient.DefaultAppVersion = BuildInfo.Version;
|
||||
var client = new LicenseClient();
|
||||
var result = await client.ValidateAsync(_dcOptions.ProjectSlug, key, _dcOptions.BaseUrl, BuildInfo.Version);
|
||||
|
||||
string detail = $"Status: {result.Status}\n{result.Message}\n\nHardware-ID dieser Maschine:\n{hw.HardwareId}";
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
_logger?.Info($"🔑 Lizenz geprüft: gültig ({result.Status}).");
|
||||
Status($"Lizenz gültig ({result.Status}).");
|
||||
await DialogWindow.Info(this, "Lizenz gültig", detail);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.Warning($"⚠️ Lizenz geprüft: nicht gültig ({result.Status}).");
|
||||
Status($"Lizenz NICHT gültig ({result.Status}).");
|
||||
await DialogWindow.Info(this, "Lizenz nicht gültig", detail);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DialogWindow.Info(this, "Fehler", $"Lizenzprüfung fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
btn.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="using:PolyTrader.App.Avalonia.Shell"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.ShellWindow"
|
||||
x:DataType="shell:ShellNavModel"
|
||||
Title="PolyTrader"
|
||||
Width="1600" Height="900"
|
||||
WindowState="Maximized">
|
||||
|
||||
<!--
|
||||
Das einzige Anwendungsfenster. Alle Ansichten (Startseite, Dashboard, Modul-Seiten,
|
||||
Einstellungen, Jobs, Terminal) sind UserControls und werden in „pageHost" gehängt –
|
||||
Nachfolger der früheren Mehrfenster-Shell mit WindowMenuBar auf jedem Fenster.
|
||||
|
||||
Aufbau nach dem Claude-Design-Mockup „PolyTrader UI Redesign":
|
||||
· links eine einklappbare Seitenleiste mit gruppierter Navigation
|
||||
· oben Kopfzeile mit Seitentitel und Farbschema-Umschalter
|
||||
· darunter die Benachrichtigungsleiste (Warnungen/Fehler, wegklickbar)
|
||||
· unten die Statusleiste (Trading, Module, API, Farbschema)
|
||||
|
||||
Layout vollständig deklarativ (Layout-Regel des Projekts). Das Code-Behind verdrahtet nur:
|
||||
Seitenwechsel, Umschalter, Statuswerte und die Benachrichtigungen.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- ══ Seitenleiste ══════════════════════════════════════════════════════════ -->
|
||||
<Border Grid.Column="0"
|
||||
Width="{Binding SidebarWidth}"
|
||||
Background="{DynamicResource AppSurfaceBrush}"
|
||||
BorderBrush="{DynamicResource AppDividerBrush}"
|
||||
BorderThickness="0,0,1,0">
|
||||
<DockPanel>
|
||||
|
||||
<!-- Marke + Einklappen -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
BorderBrush="{DynamicResource AppDividerBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="12,11">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Name="btnCollapse"
|
||||
Classes="ghost"
|
||||
Content="{Binding CollapseGlyph}"
|
||||
ToolTip.Tip="{Binding CollapseToolTip}"
|
||||
Width="22" Height="22" Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalContentAlignment="Center" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<!-- Bildmarke des Mockups: aufsteigende Kurve mit Pfeilecke. -->
|
||||
<Viewbox Width="19" Height="19" VerticalAlignment="Center">
|
||||
<Canvas Width="24" Height="24">
|
||||
<Path Data="M22 7 L13.5 15.5 L8.5 10.5 L2 17"
|
||||
Stroke="{DynamicResource AppAccentBrush}" StrokeThickness="1.6" />
|
||||
<Path Data="M16 7 H22 V13"
|
||||
Stroke="{DynamicResource AppAccentBrush}" StrokeThickness="1.6" />
|
||||
</Canvas>
|
||||
</Viewbox>
|
||||
<TextBlock Text="PolyTrader" Classes="h5"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding LabelsVisible}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Fußzeile: Beenden + Version -->
|
||||
<Border DockPanel.Dock="Bottom"
|
||||
BorderBrush="{DynamicResource AppDividerBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="10,8">
|
||||
<StackPanel Spacing="6">
|
||||
<Button Name="btnQuit" Classes="nav"
|
||||
Classes.icononly="{Binding !LabelsVisible}"
|
||||
ToolTip.Tip="PolyTrader beenden (mit Sicherheitsabfrage)">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="⏻" FontSize="14" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Beenden" VerticalAlignment="Center"
|
||||
IsVisible="{Binding LabelsVisible}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<TextBlock Name="lblVersion" Classes="muted" FontSize="10.5"
|
||||
Margin="9,0,0,0"
|
||||
IsVisible="{Binding LabelsVisible}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Navigation: Abschnitte, darin die Seiten -->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Groups}" Margin="8,14">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="shell:ShellNavGroup">
|
||||
<StackPanel Margin="0,0,0,16">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
Classes="navgroup"
|
||||
IsVisible="{Binding LabelsVisible}" />
|
||||
<ItemsControl ItemsSource="{Binding Entries}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="shell:ShellNavEntry">
|
||||
<Button Classes="nav"
|
||||
Classes.active="{Binding IsActive}"
|
||||
Classes.icononly="{Binding IconOnly}"
|
||||
IsEnabled="{Binding IsAvailable}"
|
||||
Command="{Binding Command}"
|
||||
ToolTip.Tip="{Binding ToolTip}"
|
||||
ToolTip.ShowOnDisabled="True">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Image Source="{Binding Icon}"
|
||||
Width="18" Height="18"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Icon, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<TextBlock Text="{Binding Title}"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding LabelsVisible}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ══ Hauptbereich ══════════════════════════════════════════════════════════ -->
|
||||
<DockPanel Grid.Column="1">
|
||||
|
||||
<!-- Kopfzeile -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
BorderBrush="{DynamicResource AppDividerBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="20,12">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Name="btnTheme"
|
||||
Classes="ghost"
|
||||
Width="30" Height="30" Padding="0"
|
||||
HorizontalContentAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding PageTitle}" Classes="h4" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Benachrichtigungen (nur sichtbar, wenn welche anliegen) -->
|
||||
<Border Name="pnlNotifications" DockPanel.Dock="Top"
|
||||
IsVisible="False"
|
||||
BorderBrush="{DynamicResource AppDividerBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<ItemsControl Name="notifications">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="shell:ShellNotification">
|
||||
<Border Classes="notify" Classes.error="{Binding IsError}" Margin="0,0,0,1">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right"
|
||||
Classes="ghost"
|
||||
Content="✕" FontSize="11"
|
||||
Width="20" Height="20" Padding="0"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding Dismiss}"
|
||||
ToolTip.Tip="Meldung ausblenden" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Source}" FontWeight="SemiBold" FontSize="12" />
|
||||
<TextBlock Text="{Binding Message}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<!-- Statusleiste -->
|
||||
<Border Classes="statusbar" DockPanel.Dock="Bottom">
|
||||
<DockPanel>
|
||||
<TextBlock Name="lblTheme" DockPanel.Dock="Right" Text="—" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Name="lblTrading" Text="Trading: —" />
|
||||
<TextBlock Text="·" />
|
||||
<TextBlock Name="lblModules" Text="Module: —" />
|
||||
<TextBlock Text="·" />
|
||||
<TextBlock Name="lblRatelimit" Text="API: —" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Inhalt: hier hängt die aktive Seite -->
|
||||
<ContentControl Name="pageHost" Margin="20,16" />
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.App.Avalonia.Shell;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTraderSharp;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// Das einzige Anwendungsfenster. Hält die Seitenleiste, die Kopf-, Benachrichtigungs- und
|
||||
/// Statusleiste und den Inhaltsbereich, in dem die Seiten hängen.
|
||||
///
|
||||
/// <para>Ersetzt die frühere Mehrfenster-Shell: statt je View ein <see cref="Window"/> mit
|
||||
/// eigener Menüleiste gibt es hier eine Navigation und einen Inhaltsbereich. Den Seitenwechsel
|
||||
/// steuert <see cref="AvaloniaUiHost"/>, das Fenster stellt dafür nur
|
||||
/// <see cref="ShowPage"/> bereit.</para>
|
||||
///
|
||||
/// <para>Layout vollständig in ShellWindow.axaml.</para>
|
||||
/// </summary>
|
||||
public partial class ShellWindow : Window
|
||||
{
|
||||
/// <summary>Mehr als so viele Meldungen gleichzeitig würden den Inhalt zu weit nach unten drücken.</summary>
|
||||
private const int MaxNotifications = 3;
|
||||
|
||||
private readonly AvaloniaUiHost _uiHost = null!;
|
||||
private readonly IServiceProvider _services = null!;
|
||||
private readonly TradingState _state = null!;
|
||||
private readonly ShellNavModel _nav = null!;
|
||||
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly ObservableCollection<ShellNotification> _notifications = new();
|
||||
|
||||
private TerminalLogger? _logger;
|
||||
private EventHandler<LogMessageEventArgs>? _logHandler;
|
||||
|
||||
public ShellWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public ShellWindow(AvaloniaUiHost uiHost, IServiceProvider services) : this()
|
||||
{
|
||||
_uiHost = uiHost;
|
||||
_services = services;
|
||||
_state = services.GetRequiredService<TradingState>();
|
||||
|
||||
_nav = new ShellNavModel(uiHost);
|
||||
DataContext = _nav;
|
||||
|
||||
this.FindControl<ItemsControl>("notifications")!.ItemsSource = _notifications;
|
||||
_notifications.CollectionChanged += (_, _) =>
|
||||
this.FindControl<Border>("pnlNotifications")!.IsVisible = _notifications.Count > 0;
|
||||
|
||||
this.FindControl<TextBlock>("lblVersion")!.Text = $"PolyTrader {BuildInfo.Version} · Avalonia";
|
||||
|
||||
this.FindControl<Button>("btnCollapse")!.Click += (_, _) => _nav.ToggleSidebar();
|
||||
this.FindControl<Button>("btnQuit")!.Click += (_, _) => _uiHost.RequestShutdown();
|
||||
this.FindControl<Button>("btnTheme")!.Click += (_, _) => ThemeManager.Toggle();
|
||||
|
||||
ThemeManager.ThemeChanged += UpdateThemeControls;
|
||||
UpdateThemeControls();
|
||||
|
||||
SubscribeNotifications();
|
||||
|
||||
_statusTimer.Tick += (_, _) => UpdateStatus();
|
||||
_statusTimer.Start();
|
||||
UpdateStatus();
|
||||
|
||||
// Das X des Fensters beendet die Anwendung – deshalb über dieselbe Sicherheitsabfrage
|
||||
// wie „Beenden" (Verhalten der früheren LauncherForm).
|
||||
Closing += (_, e) =>
|
||||
{
|
||||
if (_uiHost.ShutdownConfirmed) return;
|
||||
e.Cancel = true;
|
||||
Dispatcher.UIThread.Post(_uiHost.RequestShutdown);
|
||||
};
|
||||
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
_statusTimer.Stop();
|
||||
ThemeManager.ThemeChanged -= UpdateThemeControls;
|
||||
if (_logger != null && _logHandler != null) _logger.OnLogMessage -= _logHandler;
|
||||
_nav.Detach();
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Seitenwechsel =====
|
||||
|
||||
/// <summary>
|
||||
/// Hängt eine Seite in den Inhaltsbereich und setzt den Titel in der Kopfzeile.
|
||||
/// Wird ausschließlich von <see cref="AvaloniaUiHost.OpenView(ModuleView)"/> gerufen –
|
||||
/// dort liegt auch die Zwischenspeicherung der Seiten.
|
||||
/// </summary>
|
||||
public void ShowPage(string title, Control page)
|
||||
{
|
||||
_nav.PageTitle = title;
|
||||
this.FindControl<ContentControl>("pageHost")!.Content = page;
|
||||
}
|
||||
|
||||
// ===== Farbschema =====
|
||||
|
||||
private void UpdateThemeControls()
|
||||
{
|
||||
bool dark = ThemeManager.IsDark;
|
||||
|
||||
// Sonne = ins Helle wechseln, Mond = ins Dunkle (Symbolik der bisherigen Menüleiste).
|
||||
var btn = this.FindControl<Button>("btnTheme")!;
|
||||
btn.Content = dark ? "☀" : "🌙";
|
||||
SetToolTip(btn, dark ? "Zum hellen Farbschema wechseln" : "Zum dunklen Farbschema wechseln");
|
||||
|
||||
this.FindControl<TextBlock>("lblTheme")!.Text = dark ? "Dunkelmodus" : "Hellmodus";
|
||||
}
|
||||
|
||||
private static void SetToolTip(Control control, string text) =>
|
||||
global::Avalonia.Controls.ToolTip.SetTip(control, text);
|
||||
|
||||
// ===== Statusleiste =====
|
||||
|
||||
private void UpdateStatus()
|
||||
{
|
||||
string trading = _state.GlobalTradingPaused
|
||||
? "Pausiert"
|
||||
: $"Live={_state.LiveTradingMode} / Demo={_state.DemoTradingMode}";
|
||||
this.FindControl<TextBlock>("lblTrading")!.Text = $"Trading: {trading}";
|
||||
|
||||
int moduleCount = _services.GetServices<IPolyTraderModule>().Count();
|
||||
this.FindControl<TextBlock>("lblModules")!.Text = $"Module: {moduleCount}";
|
||||
|
||||
this.FindControl<TextBlock>("lblRatelimit")!.Text =
|
||||
$"API: {(_state.IsAlchemyHealthy ? "WSS aktiv" : "Polling")}";
|
||||
}
|
||||
|
||||
// ===== Benachrichtigungsleiste =====
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt Warnungen und Fehler live an, sobald sie im Log auflaufen. Bewusst nur diese beiden
|
||||
/// Stufen: alles andere gehört in die Terminal-Seite und nicht über jede Seite.
|
||||
///
|
||||
/// <para>Die wichtigsten Meldungen fallen beim <b>Start</b> an – fehlende Lizenz (dann laufen
|
||||
/// keine Module), fehlender Master-Key, keine Datenbank. Die entstehen alle, bevor dieses
|
||||
/// Fenster existiert, ein reines Abo würde sie also verpassen. Deshalb wird die Leiste
|
||||
/// zuerst aus dem jüngsten Verlauf gefüllt.</para>
|
||||
/// </summary>
|
||||
private void SubscribeNotifications()
|
||||
{
|
||||
_logger = _services.GetService<TerminalLogger>();
|
||||
if (_logger == null) return;
|
||||
|
||||
foreach (var e in _logger.GetHistory(TimeSpan.FromMinutes(10))
|
||||
.Where(IsNoteworthy)
|
||||
.TakeLast(MaxNotifications))
|
||||
{
|
||||
Add(e);
|
||||
}
|
||||
|
||||
_logHandler = (_, e) =>
|
||||
{
|
||||
if (!IsNoteworthy(e)) return;
|
||||
Dispatcher.UIThread.Post(() => Add(e));
|
||||
};
|
||||
_logger.OnLogMessage += _logHandler;
|
||||
}
|
||||
|
||||
private static bool IsNoteworthy(LogMessageEventArgs e) =>
|
||||
e.Level == LogLevel.Warning || e.Level == LogLevel.Error;
|
||||
|
||||
private void Add(LogMessageEventArgs e)
|
||||
{
|
||||
var note = new ShellNotification
|
||||
{
|
||||
Source = e.Timestamp.ToString("HH:mm:ss"),
|
||||
Message = OneLine(e.Message),
|
||||
IsError = e.Level == LogLevel.Error
|
||||
};
|
||||
note.Dismiss = new RelayCommand(() => _notifications.Remove(note));
|
||||
|
||||
_notifications.Insert(0, note);
|
||||
while (_notifications.Count > MaxNotifications)
|
||||
_notifications.RemoveAt(_notifications.Count - 1);
|
||||
}
|
||||
|
||||
private static string OneLine(string s) =>
|
||||
(s ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.TerminalWindow"
|
||||
Title="Terminal / Logs"
|
||||
Width="1429" Height="1083">
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.TerminalWindow">
|
||||
|
||||
<!--
|
||||
Terminal- und Log-Ansicht.
|
||||
@@ -17,8 +14,6 @@
|
||||
Zeilen verdrängt, der sichtbare Verlauf bleibt erhalten.
|
||||
-->
|
||||
<DockPanel>
|
||||
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
|
||||
|
||||
<TabControl>
|
||||
|
||||
<TabItem Header="Live">
|
||||
@@ -94,4 +89,4 @@
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
</Window>
|
||||
</UserControl>
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
/// Terminal-/Log-Ansicht. Live-Ausgabe des <see cref="TerminalLogger"/> plus Viewer für die
|
||||
/// JSONL-Tagesdateien. Layout vollständig in TerminalWindow.axaml.
|
||||
/// </summary>
|
||||
public partial class TerminalWindow : Window
|
||||
public partial class TerminalWindow : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Obergrenze der angezeigten Zeilen. Die WinForms-Fassung leerte bei Erreichen einer
|
||||
@@ -51,8 +51,6 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
|
||||
public TerminalWindow(IModuleUiHost host, TerminalLogger logger) : this()
|
||||
{
|
||||
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.terminal", this);
|
||||
|
||||
_logger = logger;
|
||||
this.FindControl<ItemsControl>("logLines")!.ItemsSource = _lines;
|
||||
this.FindControl<DataGrid>("gridLogs")!.ItemsSource = _viewerRows;
|
||||
@@ -94,11 +92,10 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
_timer.Tick += (_, _) => ProcessQueue();
|
||||
_timer.Start();
|
||||
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
_timer.Stop();
|
||||
if (_logger != null && _logHandler != null) _logger.OnLogMessage -= _logHandler;
|
||||
};
|
||||
// Kein Abmelden mehr: Die Seite wird in der Einfenster-Shell einmal erzeugt und läuft
|
||||
// bis zum Programmende weiter – genau wie früher ein offen gelassenes Fenster. Der
|
||||
// Takt bleibt deshalb absichtlich an, damit der Verlauf beim Zurückwechseln vollständig
|
||||
// ist und keine Meldungen fehlen.
|
||||
|
||||
ProcessQueue();
|
||||
}
|
||||
@@ -166,7 +163,9 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
var sb = new StringBuilder();
|
||||
foreach (var l in _lines) sb.AppendLine(l.Text);
|
||||
|
||||
var clipboard = GetTopLevel(this)?.Clipboard;
|
||||
// Seit der Umstellung auf Seiten ist „this" kein TopLevel mehr – die Zwischenablage
|
||||
// hängt am Shell-Fenster und wird über den Baum aufgelöst.
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard != null) await clipboard.SetTextAsync(sb.ToString());
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.",
|
||||
"MySqlConnectionString": ""
|
||||
},
|
||||
"Deploymentcenter": {
|
||||
"_comment": "Token und LicenseKey liegen in der gitignorierten appsettings.Local.json (Secrets gehören nicht in die versionierte Datei).",
|
||||
"BaseUrl": "https://dc.mhdf.de",
|
||||
"ProjectSlug": "polytrader",
|
||||
"Channel": "prod",
|
||||
"Token": "",
|
||||
"LicenseKey": "",
|
||||
"WatchdogSource": "polytrader",
|
||||
"WatchdogInstance": "prod-1",
|
||||
"WatchdogIntervalSeconds": 60,
|
||||
"WatchdogEnabled": false,
|
||||
"ErrorReportingEnabled": false,
|
||||
"UpdateCheckEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -79,16 +79,16 @@ namespace PolyTraderSharp.Models
|
||||
|
||||
[Category("Watchdog Monitoring")]
|
||||
[DisplayName("Watchdog Enabled")]
|
||||
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den externen " +
|
||||
"Watchdog-Server. Bleiben die Heartbeats aus, alarmiert der Watchdog (Dead-Man's-Switch). " +
|
||||
"Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
|
||||
[Description("Wenn true, sendet PolyTrader im festen Takt einen Heartbeat an den Watchdog " +
|
||||
"des Deploymentcenters. Bleiben die Heartbeats aus, alarmiert der Watchdog " +
|
||||
"(Dead-Man's-Switch). Ein nicht erreichbarer Watchdog beeinträchtigt PolyTrader NIE.")]
|
||||
public bool WatchdogEnabled { get; set; } = false;
|
||||
|
||||
[Category("Watchdog Monitoring")]
|
||||
[DisplayName("Watchdog URL")]
|
||||
[Description("Basis-URL des Watchdog-Servers, z.B. https://watchdog.example.de. " +
|
||||
"HTTPS ist Pflicht – http:// wird nur für localhost akzeptiert, damit der Agent-Token " +
|
||||
"nicht im Klartext über das Netz geht.")]
|
||||
[DisplayName("Deploymentcenter-URL")]
|
||||
[Description("Basis-URL des Deploymentcenters, z.B. https://dc.mhdf.de (ersetzt den früheren " +
|
||||
"eigenständigen Watchdog-Server). HTTPS ist Pflicht – http:// wird nur für localhost " +
|
||||
"akzeptiert, damit der Agent-Token nicht im Klartext über das Netz geht.")]
|
||||
public string WatchdogUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,21 +4,26 @@ using System.Collections.Generic;
|
||||
namespace PolyTrader.Core.Modularity
|
||||
{
|
||||
/// <summary>
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Fenster-Ansicht. Die Shell zeigt je View
|
||||
/// höchstens eine Instanz und holt ein bereits offenes Fenster wieder in den Vordergrund.
|
||||
/// Eine vom Core oder einem Modul beigesteuerte Ansicht. Die Shell zeigt je View höchstens eine
|
||||
/// Instanz und holt eine bereits erzeugte wieder nach vorn.
|
||||
///
|
||||
/// <para><b>Bewusst toolkit-neutral:</b> <see cref="CreateView"/> liefert ein <see cref="object"/>,
|
||||
/// keinen konkreten Fenstertyp. Dadurch bleibt der Core plattformunabhängig (<c>net8.0</c>) und
|
||||
/// trägt keine WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet:
|
||||
/// die WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die künftige Avalonia-Shell auf
|
||||
/// <c>Avalonia.Controls.Window</c>. Module liefern das Fenster, die Shell hängt es auf.</para>
|
||||
/// keinen konkreten Anzeigetyp. Dadurch bleibt der Core plattformunabhängig und trägt keine
|
||||
/// WinForms-Abhängigkeit mehr. Die jeweilige Shell kennt ihr Toolkit und castet: die frühere
|
||||
/// WinForms-Shell auf <c>System.Windows.Forms.Form</c>, die Avalonia-Shell auf
|
||||
/// <c>Avalonia.Controls.Control</c>.</para>
|
||||
///
|
||||
/// <para><b>Anzeigeform:</b> Die Avalonia-Shell ist seit dem UI-Umbau eine <b>Einfenster-Shell</b> –
|
||||
/// eine View ist dort eine Seite (<c>UserControl</c>) im Inhaltsbereich des einzigen Fensters,
|
||||
/// kein eigenes Fenster mehr. Der Vertrag hier bleibt davon unberührt; nur die Begriffe „offen"
|
||||
/// und „Vordergrund" bedeuten jetzt „erzeugt" bzw. „angezeigt".</para>
|
||||
/// </summary>
|
||||
public sealed class ModuleView
|
||||
{
|
||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur ein Fenster je View).</summary>
|
||||
/// <summary>Stabile ID für Einzelinstanz-Handling (nur eine Instanz je View).</summary>
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>Titel (Fallback-Fenstertitel, falls das Fenster keinen eigenen setzt).</summary>
|
||||
/// <summary>Titel der Ansicht – in der Avalonia-Shell die Beschriftung in Seitenleiste und Kopfzeile.</summary>
|
||||
public string Title { get; init; } = "Fenster";
|
||||
|
||||
/// <summary>Optionale Gruppierung (z.B. "Core", "CopyTrading").</summary>
|
||||
@@ -37,7 +42,7 @@ namespace PolyTrader.Core.Modularity
|
||||
public string? IconKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt das anzuzeigende Fenster (frische Instanz je Öffnung). Rückgabetyp ist
|
||||
/// Erzeugt die anzuzeigende Ansicht. Rückgabetyp ist
|
||||
/// <see cref="object"/> – siehe Klassen-Doku zur Toolkit-Neutralität.
|
||||
/// </summary>
|
||||
public Func<object> CreateView { get; init; } = () => new object();
|
||||
@@ -45,9 +50,9 @@ namespace PolyTrader.Core.Modularity
|
||||
|
||||
/// <summary>
|
||||
/// Wird der Shell beim Start übergeben; Core und Module registrieren hier ihre Ansichten.
|
||||
/// Die Shell (Launcher) öffnet die Fenster auf Anforderung und verwaltet den Offen-Status.
|
||||
/// Über die Navigations-Mitglieder kann JEDES Fenster (auch Modul-Fenster, die nur den Core
|
||||
/// kennen) das gemeinsame „Fenster"-Menü bauen.
|
||||
/// Die Shell zeigt eine Ansicht auf Anforderung an und verwaltet ihren Zustand. Über die
|
||||
/// Navigations-Mitglieder kann JEDE Ansicht (auch Modul-Ansichten, die nur den Core kennen)
|
||||
/// zu einer anderen wechseln oder das Herunterfahren einleiten.
|
||||
/// </summary>
|
||||
public interface IModuleUiHost
|
||||
{
|
||||
@@ -56,23 +61,23 @@ namespace PolyTrader.Core.Modularity
|
||||
/// <summary>Alle registrierten Ansichten (Core + Module).</summary>
|
||||
IReadOnlyList<ModuleView> Views { get; }
|
||||
|
||||
/// <summary>Ist das Fenster dieser View gerade offen?</summary>
|
||||
/// <summary>Wurde diese View bereits erzeugt (in der Mehrfenster-Fassung: „Fenster ist offen")?</summary>
|
||||
bool IsOpen(string viewId);
|
||||
|
||||
/// <summary>Öffnet die View bzw. holt ein bereits offenes Fenster in den Vordergrund (maximiert).</summary>
|
||||
/// <summary>Zeigt die View an bzw. holt eine bereits erzeugte wieder nach vorn.</summary>
|
||||
void OpenView(string viewId);
|
||||
|
||||
/// <summary>Holt das Hauptfenster (Launcher) in den Vordergrund.</summary>
|
||||
/// <summary>Zurück zur Startansicht.</summary>
|
||||
void ActivateMain();
|
||||
|
||||
/// <summary>
|
||||
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Wird vom „Beenden"-
|
||||
/// Eintrag JEDES Fensters aufgerufen – auch aus Modul-Fenstern, die die App nicht kennen.
|
||||
/// Die konkrete Shell zeigt den Dialog und fährt bei Bestätigung geordnet herunter.
|
||||
/// Leitet das sichere Herunterfahren ein (Bestätigungsdialog mit Timer). Aus jeder Ansicht
|
||||
/// aufrufbar – auch aus Modul-Ansichten, die die App nicht kennen. Die konkrete Shell zeigt
|
||||
/// den Dialog und fährt bei Bestätigung geordnet herunter.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
|
||||
/// <summary>Feuert, wenn sich der Offen-Status irgendeiner View ändert.</summary>
|
||||
/// <summary>Feuert, wenn eine andere View angezeigt oder erstmals erzeugt wird.</summary>
|
||||
event Action? OpenStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Nethereum.Web3" Version="6.1.0" />
|
||||
<!-- Deploymentcenter-Integration (Lizenz, Watchdog, Fehler-Reporting, Updates) - siehe
|
||||
docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md. Lokales Paket in
|
||||
lib/nuget, keine Projektreferenz ins Nachbar-Repo (PolyTrader muss ohne das
|
||||
Schwester-Repo bauen, siehe lib/nuget/README.md). Erzeugt beim Bauen zusätzlich die
|
||||
Klasse PolyTrader.Core.BuildInfo (Version, Git-Commit, Build-Datum) über
|
||||
Deploymentcenter.BuildInfo.targets, das im Paket unter build/ liegt. -->
|
||||
<PackageReference Include="Deploymentcenter.Client" Version="2.5.0" />
|
||||
<!-- Sicherheit: Nethereum 6.1.0 laesst transitiv Newtonsoft.Json [11.0.2, 14.0.0) zu und
|
||||
wuerde ohne Pinnung auf 11.0.2 aufloesen - das hat eine bekannte Luecke hoher Schwere
|
||||
(GHSA-5crp-9r3c-p9vr). Die .NET-10-Paketpruefung meldet das jetzt auch fuer transitive
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace PolyTrader.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Beschränkt eine Schlüsseldatei auf den Besitzer. Unter Linux/macOS erzeugt
|
||||
/// <see cref="File.WriteAllText(string, string)"/> mit der üblichen umask sonst <c>644</c> —
|
||||
/// world-readable, und genau dort liegen Master-Key, Watchdog-Token oder OpenRouter-Key im
|
||||
/// Klartext. Unter Windows greift die NTFS-Vererbung des Profils, dort ist nichts zu tun.
|
||||
/// </summary>
|
||||
public static class FilePermissions
|
||||
{
|
||||
public static void RestrictToOwner(string path, Action<string>? onWarning = null)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onWarning?.Invoke(
|
||||
$"Dateirechte für {Path.GetFileName(path)} konnten nicht gesetzt werden: {ex.Message}. Bitte manuell prüfen (chmod 600).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PolyTrader.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Löst den AES-Master-Key auf, mit dem <see cref="SecretProtection"/> Secrets at-rest
|
||||
/// verschlüsselt (D-5/D-10 aus dem Deploymentcenter-Umsetzungsplan).
|
||||
///
|
||||
/// Reihenfolge: Umgebungsvariable, dann eine vorhandene Schlüsseldatei, sonst wird ein neuer
|
||||
/// erzeugt und geschrieben — das ist der Regelfall direkt nach <c>update-agent --action install</c>
|
||||
/// auf einem frischen System, wo niemand von Hand einen Schlüssel eintippt.
|
||||
///
|
||||
/// **Die eine Regel, die nie verletzt werden darf:** Erzeugt wird AUSSCHLIESSLICH, wenn weder
|
||||
/// Variable noch Datei existieren. Ein versehentliches Überschreiben würde jedes bereits
|
||||
/// verschlüsselte Secret in der Datenbank unwiederbringlich unlesbar machen — der einzige
|
||||
/// wirklich unumkehrbare Fehler in diesem gesamten Bereich. Ein zweiter Aufruf (jeder weitere
|
||||
/// Programmstart) liest deshalb ausnahmslos die vorhandene Datei, statt neu zu erzeugen.
|
||||
/// </summary>
|
||||
public static class MasterKeyResolver
|
||||
{
|
||||
/// <param name="keyFilePath">Pfad der Schlüsseldatei (z.B. neben der Anwendung).</param>
|
||||
/// <param name="readEnvironmentVariable">Liest z.B. POLYTRADER_MASTER_KEY. Injizierbar für Tests.</param>
|
||||
/// <param name="onGenerated">Aufgerufen, wenn ein neuer Schlüssel erzeugt wurde (Pfad als Parameter).</param>
|
||||
/// <param name="onWarning">Aufgerufen bei einem nicht fatalen Problem (z.B. Dateirechte, Erzeugung fehlgeschlagen).</param>
|
||||
public static string? Resolve(
|
||||
string keyFilePath,
|
||||
Func<string?> readEnvironmentVariable,
|
||||
Action<string>? onGenerated = null,
|
||||
Action<string>? onWarning = null)
|
||||
{
|
||||
string? masterKey = readEnvironmentVariable();
|
||||
if (!string.IsNullOrWhiteSpace(masterKey))
|
||||
{
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
if (File.Exists(keyFilePath))
|
||||
{
|
||||
return File.ReadAllText(keyFilePath).Trim();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] keyBytes = RandomNumberGenerator.GetBytes(32); // 256-Bit-Schlüssel
|
||||
string generated = Convert.ToBase64String(keyBytes);
|
||||
|
||||
File.WriteAllText(keyFilePath, generated);
|
||||
FilePermissions.RestrictToOwner(keyFilePath, onWarning);
|
||||
|
||||
onGenerated?.Invoke(keyFilePath);
|
||||
return generated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Erzeugung darf den Start nie verhindern - ohne Master-Key laufen Secrets eben
|
||||
// unverschlüsselt weiter (wie bisher ohne POLYTRADER_MASTER_KEY), statt abzustürzen.
|
||||
onWarning?.Invoke($"Master-Key konnte nicht automatisch erzeugt werden: {ex.Message}.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PolyTrader.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Entfernt bekannte Secret-Muster aus Text, der an extern gehostete Dienste (Deploymentcenter:
|
||||
/// Watchdog, Fehler-Reporting) geht. Nur die Musterprüfung selbst — Längenbegrenzung und
|
||||
/// Zeilenbehandlung entscheidet jeder Aufrufer selbst (eine kurze Statuszeile und ein mehrzeiliger
|
||||
/// Stacktrace brauchen unterschiedliche Regeln).
|
||||
/// </summary>
|
||||
public static class SecretRedactor
|
||||
{
|
||||
/// <summary>Entfernt Wallet-Adressen/Keys (0x-Hex) und verschlüsselte Secrets (enc:v1:…).</summary>
|
||||
public static string RemoveSecrets(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return string.Empty;
|
||||
string s = Regex.Replace(text, "0x[0-9a-fA-F]{8,}", "0x…");
|
||||
s = Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…");
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.Security;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Meldet ungefangene Fehler an den Fehler-Stream des Deploymentcenters (D-3,
|
||||
/// <c>POST /api/errors/v1/report</c>). Fängt drei Quellen ab: <see cref="AppDomain.UnhandledException"/>
|
||||
/// (der Prozess stirbt gerade – <c>level: "fatal"</c>), <see cref="TaskScheduler.UnobservedTaskException"/>
|
||||
/// (ein Hintergrund-Task ist fehlgeschlagen, ohne dass jemand hinsah – <c>"warning"</c>) und
|
||||
/// <see cref="TerminalLogger.OnLogMessage"/> bei <see cref="LogLevel.Error"/> (<c>"error"</c>).
|
||||
///
|
||||
/// **Grundregel wie beim Watchdog:** Ein nicht erreichbares Deploymentcenter darf PolyTrader nie
|
||||
/// beeinträchtigen. Zusätzlich hier besonders wichtig: **keine Rückkopplung.** Der Reporter selbst
|
||||
/// darf einen Sendefehler NIEMALS über <see cref="TerminalLogger.Error"/> melden – das würde exakt
|
||||
/// den Log-Kanal auslösen, den er selbst abhört, und sich damit endlos selbst melden. Eigene
|
||||
/// Fehlermeldungen laufen deshalb ausschließlich über <see cref="TerminalLogger.Warning"/>.
|
||||
///
|
||||
/// Fehlermeldungen enthalten möglicherweise Wallet-Adressen, Marktkennungen oder Beträge; der
|
||||
/// Deploymentcenter-Server liegt beim Hoster. Nachricht und Stacktrace laufen deshalb vor dem
|
||||
/// Versand durch <see cref="SecretRedactor"/>.
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterErrorReporter : BackgroundService
|
||||
{
|
||||
/// <summary>
|
||||
/// Eigene, deutlich strengere Grenze als das serverseitige Limit (60/Minute/IP) – ein
|
||||
/// durchdrehender Fehlerpfad soll lokal gedrosselt werden, bevor er den Server überhaupt
|
||||
/// erreicht oder mit anderen Diensten auf derselben IP kollidiert.
|
||||
/// </summary>
|
||||
private const int MaxReportsPerMinute = 20;
|
||||
|
||||
private const int MaxStackTraceLength = 4000;
|
||||
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly DeploymentcenterOptions _options;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
private readonly object _rateLock = new();
|
||||
private readonly Queue<DateTime> _recentSendsUtc = new();
|
||||
private bool _throttleWarningLogged;
|
||||
|
||||
public DeploymentcenterErrorReporter(
|
||||
TerminalLogger logger,
|
||||
DeploymentcenterOptions options,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options;
|
||||
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
_logger.OnLogMessage += OnAppLog;
|
||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||
}
|
||||
|
||||
/// <summary>Rein ereignisgesteuert – kein eigener Takt nötig, nur Auf- und Abbau der Abonnements.</summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normales Herunterfahren.
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_logger.OnLogMessage -= OnAppLog;
|
||||
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
internal void OnAppLog(object? sender, LogMessageEventArgs e)
|
||||
{
|
||||
if (e.Level != LogLevel.Error) return;
|
||||
// Fire-and-forget: OnLogMessage ist synchron, darf den loggenden Thread nicht blockieren.
|
||||
// ReportAsync fängt jede eigene Ausnahme selbst ab (No-Throw-Vertrag) – hier entsteht also
|
||||
// keine unbeobachtete Task-Exception, die wiederum den UnobservedTaskException-Zweig
|
||||
// triggern und sich selbst melden würde.
|
||||
_ = ReportAsync("ApplicationError", e.Message, stackTrace: null, level: "error");
|
||||
}
|
||||
|
||||
internal void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
string message = (e.ExceptionObject as Exception)?.Message ?? e.ExceptionObject?.ToString() ?? "Unbekannter Fehler";
|
||||
string? stack = (e.ExceptionObject as Exception)?.StackTrace;
|
||||
string type = (e.ExceptionObject as Exception)?.GetType().FullName ?? "UnknownException";
|
||||
|
||||
// Der Prozess beendet sich gleich (IsTerminating in aller Regel true) - synchron und mit
|
||||
// kurzer Frist senden, statt auf einen Fire-and-forget-Task zu setzen, der nie mehr laufen
|
||||
// könnte.
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
ReportAsync(type, message, stack, "fatal", cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch { /* Der Prozess stirbt ohnehin - hier ist nichts mehr zu retten. */ }
|
||||
}
|
||||
|
||||
internal void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
var ex = e.Exception?.Flatten().InnerException ?? (Exception?)e.Exception;
|
||||
_ = ReportAsync(ex?.GetType().FullName ?? "UnobservedTaskException",
|
||||
ex?.Message ?? "Unbeobachtete Task-Exception", ex?.StackTrace, "warning");
|
||||
|
||||
// Verhindert, dass .NET den Prozess wegen der unbeobachteten Exception beendet - wir haben
|
||||
// sie ja gerade beobachtet und gemeldet.
|
||||
e.SetObserved();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet einen Fehler. Wirft nie, meldet eigene Fehlschläge nur über
|
||||
/// <see cref="TerminalLogger.Warning"/> (siehe Klassendokumentation zur Rückkopplung).
|
||||
/// </summary>
|
||||
public async Task<bool> ReportAsync(
|
||||
string exceptionType, string message, string? stackTrace, string level, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_options.ErrorReportingEnabled) return false;
|
||||
if (string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.Token))
|
||||
return false;
|
||||
|
||||
if (!TryBuildEndpoint(_options.BaseUrl, out Uri? uri, out string urlProblem))
|
||||
{
|
||||
WarnOnce($"⚠️ Fehler-Reporting nicht sendebereit: {urlProblem}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReserveRateSlot())
|
||||
{
|
||||
if (!_throttleWarningLogged)
|
||||
{
|
||||
_logger.Warning("⚠️ Fehler-Reporting lokal gedrosselt (mehr als " +
|
||||
$"{MaxReportsPerMinute} Meldungen/Minute) – weitere Meldungen werden " +
|
||||
"verworfen, bis sich die Rate beruhigt. Details bleiben im Terminal-Log.");
|
||||
_throttleWarningLogged = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
string token;
|
||||
try { token = SecretProtection.Unprotect(_options.Token); }
|
||||
catch (Exception ex) { WarnOnce($"⚠️ Fehler-Reporting-Token nicht lesbar: {ex.Message}"); return false; }
|
||||
|
||||
var payload = new
|
||||
{
|
||||
project_slug = _options.ProjectSlug,
|
||||
exception = exceptionType,
|
||||
message = WatchdogHeartbeatService.Sanitize(message),
|
||||
stack_trace = SanitizeStackTrace(stackTrace),
|
||||
level,
|
||||
build = BuildInfo.Version,
|
||||
environment = Environment.GetEnvironmentVariable("POLYTRADER_ENVIRONMENT") ?? "production"
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
WarnOnce($"⚠️ Fehler-Reporting fehlgeschlagen: HTTP {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Letztes Sicherheitsnetz - siehe Klassendokumentation: NIE über .Error loggen.
|
||||
WarnOnce($"⚠️ Fehler-Reporting unerwartet fehlgeschlagen: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Entprellt Warnungen wie der Watchdog: der erste Fehlschlag wird gemeldet, nicht jeder einzelne.</summary>
|
||||
private bool _lastSendFailed;
|
||||
private void WarnOnce(string message)
|
||||
{
|
||||
if (_lastSendFailed) return;
|
||||
_logger.Warning(message);
|
||||
_lastSendFailed = true;
|
||||
}
|
||||
|
||||
/// <summary>Gleitendes Ein-Minuten-Fenster, ohne Hintergrund-Timer.</summary>
|
||||
private bool TryReserveRateSlot()
|
||||
{
|
||||
lock (_rateLock)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
while (_recentSendsUtc.Count > 0 && (now - _recentSendsUtc.Peek()) > TimeSpan.FromMinutes(1))
|
||||
_recentSendsUtc.Dequeue();
|
||||
|
||||
if (_recentSendsUtc.Count >= MaxReportsPerMinute) return false;
|
||||
|
||||
_recentSendsUtc.Enqueue(now);
|
||||
_throttleWarningLogged = false; // erneutes Überschreiten wieder genau einmal melden
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stacktraces dürfen (anders als die kurze Statuszeile im Heartbeat) mehrzeilig bleiben –
|
||||
/// sie sind sonst wertlos. Nur die bekannten Secret-Muster werden entfernt, dazu eine
|
||||
/// großzügige Längenbegrenzung gegen ausufernde Payloads.
|
||||
/// </summary>
|
||||
private static string? SanitizeStackTrace(string? stackTrace)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stackTrace)) return null;
|
||||
string s = SecretRedactor.RemoveSecrets(stackTrace);
|
||||
return s.Length > MaxStackTraceLength ? s.Substring(0, MaxStackTraceLength - 1) + "…" : s;
|
||||
}
|
||||
|
||||
private static bool TryBuildEndpoint(string baseUrl, out Uri? uri, out string problem)
|
||||
{
|
||||
uri = null;
|
||||
problem = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(baseUrl?.Trim().TrimEnd('/'), UriKind.Absolute, out Uri? parsed))
|
||||
{
|
||||
problem = $"Deploymentcenter-URL ist keine gültige absolute URL: '{baseUrl}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isHttps = parsed.Scheme == Uri.UriSchemeHttps;
|
||||
bool isLocalHttp = parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback;
|
||||
if (!isHttps && !isLocalHttp)
|
||||
{
|
||||
problem = $"Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{baseUrl}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/errors/v1/report");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PolyTrader.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTraderSharp.Models;
|
||||
@@ -13,16 +16,25 @@ namespace PolyTraderSharp.Services
|
||||
public sealed record WatchdogSendResult(bool Success, string Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Sendet im festen Takt einen Heartbeat an den externen Watchdog-Server (Dead-Man's-Switch).
|
||||
/// Bleiben die Heartbeats aus, alarmiert der Watchdog – er läuft bewusst auf einer anderen
|
||||
/// Maschine und bemerkt damit auch einen harten Server-Crash.
|
||||
/// Sendet im festen Takt einen Heartbeat an den Watchdog des Deploymentcenters
|
||||
/// (Dead-Man's-Switch). Bleiben die Heartbeats aus, alarmiert der Watchdog – er läuft bewusst
|
||||
/// auf einer anderen Maschine und bemerkt damit auch einen harten Server-Crash.
|
||||
///
|
||||
/// **Umstellung 14.08.2026:** Ersetzt den eigenständigen Watchdog-Dienst (watchdog.mhdf.de) und
|
||||
/// LicenseLabrador durch das Deploymentcenter (siehe
|
||||
/// docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-1). Endpunkte
|
||||
/// sind jetzt <c>/api/watchdog/v1/ping</c> und <c>/api/watchdog/v1/event</c>, Auth-Header
|
||||
/// <c>Authorization: Bearer</c> statt <c>X-Watchdog-Key</c>. <see cref="ServerSettings.WatchdogUrl"/>
|
||||
/// und <see cref="ServerSettings.WatchdogToken"/> zeigen ab jetzt auf das Deploymentcenter statt
|
||||
/// auf den alten Einzeldienst – UI, Maskierung und Live-Reload bleiben unverändert, es ist
|
||||
/// dieselbe erprobte Mechanik (21+ Tests), nur mit neuem Gegenüber.
|
||||
///
|
||||
/// **Grundregel:** Ein Ausfall des Watchdogs darf PolyTrader NIEMALS beeinträchtigen. Deshalb
|
||||
/// ist jeder Sendeversuch vollständig gekapselt; aus <see cref="ExecuteAsync"/> darf keine
|
||||
/// Ausnahme entweichen (ein werfender BackgroundService würde den Host beenden).
|
||||
///
|
||||
/// Bewusst eine eigene Implementierung statt des mitgelieferten Watchdog-Testclients: dieser
|
||||
/// deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und
|
||||
/// Bewusst eine eigene Implementierung statt des mitgelieferten Deploymentcenter-Testclients:
|
||||
/// dieser deaktiviert die TLS-Zertifikatsprüfung pauschal. Hier gilt die Standardprüfung, und
|
||||
/// <c>http://</c> ist nur für localhost erlaubt, damit der Agent-Token nicht im Klartext
|
||||
/// über das Netz geht.
|
||||
/// </summary>
|
||||
@@ -65,6 +77,16 @@ namespace PolyTraderSharp.Services
|
||||
private string? _lastAppErrorMessage;
|
||||
private bool _startedEventSent;
|
||||
|
||||
/// <summary>
|
||||
/// D-1: Selbst ermittelte Gesundheitsprüfungen (z.B. VPN verbunden, DB erreichbar), die der
|
||||
/// Deploymentcenter-Watchdog unter "checks" auswertet – ein als "ok" gemeldeter Heartbeat mit
|
||||
/// fehlschlagender Prüfung wird dort automatisch auf "warning" herabgestuft. Der Service
|
||||
/// interpretiert die Namen nicht selbst, er sammelt nur ein. Composition Root (Program.cs)
|
||||
/// registriert die konkreten Prüfungen.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, Func<(bool ok, string? message)>> _checks = new();
|
||||
private readonly object _checksLock = new();
|
||||
|
||||
public WatchdogHeartbeatService(
|
||||
TerminalLogger logger,
|
||||
HttpClient? httpClient = null,
|
||||
@@ -103,6 +125,53 @@ namespace PolyTraderSharp.Services
|
||||
/// <summary>Lädt die Einstellungen neu (nach dem Speichern im Settings-Fenster).</summary>
|
||||
public void ReloadSettings() => _settings = _settingsLoader();
|
||||
|
||||
/// <summary>
|
||||
/// D-1: Registriert eine benannte Gesundheitsprüfung, die bei jedem Heartbeat ausgewertet
|
||||
/// wird. Eine zweite Registrierung unter demselben Namen ersetzt die vorherige. Der Aufruf
|
||||
/// selbst darf nicht werfen – ein Fehler in einer Prüfung führt sonst nur dazu, dass diese
|
||||
/// eine Prüfung als "nicht ok" gemeldet wird, nie dazu, dass der Heartbeat ausfällt.
|
||||
/// </summary>
|
||||
public void RegisterCheck(string name, Func<(bool ok, string? message)> check)
|
||||
{
|
||||
lock (_checksLock) { _checks[name] = check; }
|
||||
}
|
||||
|
||||
/// <summary>Entfernt eine zuvor registrierte Prüfung (z.B. wenn ein Dienst abgeschaltet wird).</summary>
|
||||
public void UnregisterCheck(string name)
|
||||
{
|
||||
lock (_checksLock) { _checks.Remove(name); }
|
||||
}
|
||||
|
||||
/// <summary>Wertet alle registrierten Prüfungen aus. Liefert <c>null</c>, wenn keine registriert sind.</summary>
|
||||
private Dictionary<string, object>? EvaluateChecks()
|
||||
{
|
||||
Dictionary<string, Func<(bool ok, string? message)>> snapshot;
|
||||
lock (_checksLock)
|
||||
{
|
||||
if (_checks.Count == 0) return null;
|
||||
snapshot = new Dictionary<string, Func<(bool ok, string? message)>>(_checks);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (var (name, check) in snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (ok, message) = check();
|
||||
result[name] = message == null
|
||||
? new { ok }
|
||||
: new { ok, message = Sanitize(message) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Eine werfende Prüfung darf den Heartbeat nicht zum Absturz bringen – sie
|
||||
// meldet sich selbst als fehlgeschlagen.
|
||||
result[name] = new { ok = false, message = $"Prüfung fehlgeschlagen: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die aktuelle Konfiguration. <c>null</c> = sendebereit, sonst ein kurzer,
|
||||
/// anzeigbarer Grund. Wird auch vom Test-Button der Settings-View genutzt.
|
||||
@@ -115,7 +184,7 @@ namespace PolyTraderSharp.Services
|
||||
return "Kein Agent-Token konfiguriert.";
|
||||
if (string.IsNullOrWhiteSpace(_settings.WatchdogSource))
|
||||
return "Keine Source konfiguriert.";
|
||||
if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out _, out string urlProblem))
|
||||
if (!TryBuildEndpoint(_settings.WatchdogUrl, PingPath, out _, out string urlProblem))
|
||||
return urlProblem;
|
||||
return null;
|
||||
}
|
||||
@@ -176,7 +245,7 @@ namespace PolyTraderSharp.Services
|
||||
// den Programmstart; danach übernimmt die Staleness-Erkennung).
|
||||
if (!_startedEventSent)
|
||||
{
|
||||
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{AppVersion})", stoppingToken)
|
||||
await SendEventAsync("started", "info", $"PolyTrader gestartet (v{BuildInfo.Version})", stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
_startedEventSent = true;
|
||||
}
|
||||
@@ -206,9 +275,12 @@ namespace PolyTraderSharp.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B.3: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende („stopped_graceful"),
|
||||
/// damit dieser NICHT fälschlich einen Absturz alarmiert. Best effort mit kurzem Timeout –
|
||||
/// blockiert das Beenden nicht nennenswert.
|
||||
/// B.3/D-1: Meldet dem Watchdog beim Herunterfahren ein geordnetes Ende. Zwei Wege, weil sie
|
||||
/// unterschiedliche Dinge tun: der Heartbeat mit <c>status:"stopped"</c> ist das, was der
|
||||
/// Evaluator auswertet (er lässt einen "stopped"-Monitor in Ruhe, statt ihn nach dem
|
||||
/// Ausbleiben weiterer Heartbeats als "down" zu alarmieren); das Event "stopped_graceful"
|
||||
/// landet zusätzlich im Ereignisprotokoll. Best effort mit kurzem Timeout – blockiert das
|
||||
/// Beenden nicht nennenswert.
|
||||
/// </summary>
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -216,7 +288,11 @@ namespace PolyTraderSharp.Services
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
// 5 s statt 3 s: zwei Aufrufe teilen sich das Budget, keiner soll dadurch
|
||||
// praktisch ohne Zeit dastehen. host.StopAsync erlaubt insgesamt bis zu 30 s.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await SendHeartbeatAsync("stopped", "PolyTrader wird geordnet beendet.", cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
await SendEventAsync("stopped_graceful", "info", "PolyTrader wird geordnet beendet.", cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
@@ -242,7 +318,7 @@ namespace PolyTraderSharp.Services
|
||||
string? problem = GetConfigurationProblem();
|
||||
if (problem != null) return new WatchdogSendResult(false, problem);
|
||||
|
||||
if (!TryBuildEndpoint(_settings.WatchdogUrl, "heartbeat", out Uri? uri, out string urlProblem))
|
||||
if (!TryBuildEndpoint(_settings.WatchdogUrl, PingPath, out Uri? uri, out string urlProblem))
|
||||
return new WatchdogSendResult(false, urlProblem);
|
||||
|
||||
string token;
|
||||
@@ -268,7 +344,11 @@ namespace PolyTraderSharp.Services
|
||||
// "interval"/"expected_interval_sec" und ignoriert "intervalSeconds" still.
|
||||
interval = EffectiveIntervalSeconds(),
|
||||
group = "Applications",
|
||||
metadata = new { version = AppVersion }
|
||||
os = RuntimeInformation.OSDescription,
|
||||
// D-1: aus BuildInfo statt aus der Assembly-Version - stimmt jetzt mit
|
||||
// Directory.Build.props <Version> überein statt stets 1.0.0.0 zu tragen.
|
||||
version = BuildInfo.Version,
|
||||
checks = EvaluateChecks()
|
||||
};
|
||||
|
||||
try
|
||||
@@ -277,7 +357,9 @@ namespace PolyTraderSharp.Services
|
||||
{
|
||||
Content = JsonContent.Create(payload)
|
||||
};
|
||||
request.Headers.Add("X-Watchdog-Key", token);
|
||||
// D-1: Authorization: Bearer statt X-Watchdog-Key - das Deploymentcenter kennt den
|
||||
// alten Header nicht.
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -330,15 +412,15 @@ namespace PolyTraderSharp.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B.3: Sendet ein Lifecycle-Event an <c>/api/event</c> (z. B. „started", „stopped_graceful").
|
||||
/// Best effort – wirft nie und blockiert das Herunterfahren nicht.
|
||||
/// B.3: Sendet ein Lifecycle-Event an <c>/api/watchdog/v1/event</c> (z. B. „started",
|
||||
/// „stopped_graceful"). Best effort – wirft nie und blockiert das Herunterfahren nicht.
|
||||
/// </summary>
|
||||
public async Task<WatchdogSendResult> SendEventAsync(string kind, string severity, string? message, CancellationToken ct = default)
|
||||
{
|
||||
string? problem = GetConfigurationProblem();
|
||||
if (problem != null) return new WatchdogSendResult(false, problem);
|
||||
|
||||
if (!TryBuildEndpoint(_settings.WatchdogUrl, "event", out Uri? uri, out string urlProblem))
|
||||
if (!TryBuildEndpoint(_settings.WatchdogUrl, EventPath, out Uri? uri, out string urlProblem))
|
||||
return new WatchdogSendResult(false, urlProblem);
|
||||
|
||||
string token;
|
||||
@@ -357,7 +439,7 @@ namespace PolyTraderSharp.Services
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = JsonContent.Create(payload) };
|
||||
request.Headers.Add("X-Watchdog-Key", token);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode
|
||||
? new WatchdogSendResult(true, $"HTTP {(int)response.StatusCode}")
|
||||
@@ -390,9 +472,10 @@ namespace PolyTraderSharp.Services
|
||||
/// </summary>
|
||||
internal static string Sanitize(string? message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return string.Empty;
|
||||
string s = System.Text.RegularExpressions.Regex.Replace(message, "0x[0-9a-fA-F]{8,}", "0x…");
|
||||
s = System.Text.RegularExpressions.Regex.Replace(s, @"enc:v1:[A-Za-z0-9+/=]+", "enc:v1:…");
|
||||
// D-3: Musterprüfung selbst liegt jetzt in SecretRedactor - gemeinsam mit dem
|
||||
// Fehler-Reporter genutzt, der dieselben Muster aber ohne Zeilen-/Längenlimit braucht
|
||||
// (ein Stacktrace ist von Natur aus mehrzeilig und soll lesbar bleiben).
|
||||
string s = SecretRedactor.RemoveSecrets(message);
|
||||
s = s.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return s.Length > 200 ? s.Substring(0, 197) + "…" : s;
|
||||
}
|
||||
@@ -401,11 +484,17 @@ namespace PolyTraderSharp.Services
|
||||
private int EffectiveIntervalSeconds() =>
|
||||
Math.Max(MinIntervalSeconds, _settings.WatchdogIntervalSeconds);
|
||||
|
||||
/// <summary>D-1: Pfad des Heartbeat-Endpunkts im Deploymentcenter (vorher "/api/heartbeat").</summary>
|
||||
internal const string PingPath = "watchdog/v1/ping";
|
||||
|
||||
/// <summary>D-1: Pfad des Event-Endpunkts im Deploymentcenter (vorher "/api/event").</summary>
|
||||
internal const string EventPath = "watchdog/v1/event";
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Ziel-URL und erzwingt TLS. <c>http://</c> ist ausschließlich für localhost
|
||||
/// erlaubt (lokales Testen); sonst würde der Agent-Token im Klartext übertragen.
|
||||
/// </summary>
|
||||
internal static bool TryBuildEndpoint(string baseUrl, string action, out Uri? uri, out string problem)
|
||||
internal static bool TryBuildEndpoint(string baseUrl, string relativePath, out Uri? uri, out string problem)
|
||||
{
|
||||
uri = null;
|
||||
problem = string.Empty;
|
||||
@@ -425,12 +514,8 @@ namespace PolyTraderSharp.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{action}");
|
||||
uri = new Uri(parsed, $"{parsed.AbsolutePath.TrimEnd('/')}/api/{relativePath}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Version der laufenden Assembly – landet als Metadatum im Heartbeat.</summary>
|
||||
private static string AppVersion =>
|
||||
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
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.NotNull(uri);
|
||||
Assert.EndsWith("/api/heartbeat", uri!.AbsoluteUri);
|
||||
Assert.EndsWith("/api/watchdog/v1/ping", uri!.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,7 +41,7 @@ namespace PolyTrader.Tests
|
||||
{
|
||||
// Über http:// ginge der Agent-Token im Klartext über das Netz.
|
||||
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.Null(uri);
|
||||
@@ -55,7 +55,7 @@ namespace PolyTrader.Tests
|
||||
[InlineData("ftp://watchdog.example.de")]
|
||||
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.NotEmpty(problem);
|
||||
@@ -65,10 +65,10 @@ namespace PolyTrader.Tests
|
||||
public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs()
|
||||
{
|
||||
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.Equal("https://example.de/watchdog/api/heartbeat", uri!.AbsoluteUri);
|
||||
Assert.Equal("https://example.de/watchdog/api/watchdog/v1/ping", uri!.AbsoluteUri);
|
||||
}
|
||||
|
||||
// ----- Konfigurationsprüfung -----
|
||||
@@ -113,8 +113,10 @@ namespace PolyTrader.Tests
|
||||
|
||||
Assert.True(result.Success, result.Detail);
|
||||
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||||
Assert.Equal("https://wd.example.de/api/heartbeat", handler.LastRequest.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("secret-token", Assert.Single(handler.LastRequest.Headers.GetValues("X-Watchdog-Key")));
|
||||
Assert.Equal("https://wd.example.de/api/watchdog/v1/ping", handler.LastRequest.RequestUri!.AbsoluteUri);
|
||||
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!);
|
||||
var root = doc.RootElement;
|
||||
@@ -127,6 +129,65 @@ namespace PolyTrader.Tests
|
||||
// Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still.
|
||||
Assert.Equal(45, root.GetProperty("interval").GetInt32());
|
||||
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]
|
||||
@@ -363,7 +424,8 @@ namespace PolyTrader.Tests
|
||||
var result = await service.SendEventAsync("started", "info", "Test");
|
||||
|
||||
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!);
|
||||
Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString());
|
||||
Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString());
|
||||
|
||||
Reference in New Issue
Block a user