Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a8a35384a |
@@ -1,6 +0,0 @@
|
|||||||
# Shell-Skripte immer mit LF auschecken, auch unter Windows.
|
|
||||||
#
|
|
||||||
# run.sh wird per CopyToPublishDirectory in das Linux-Paket uebernommen. Mit
|
|
||||||
# core.autocrlf=true traegt die Arbeitskopie sonst CRLF, und die Zeile
|
|
||||||
# "#!/usr/bin/env bash\r" laesst den Start dort an "bad interpreter" scheitern.
|
|
||||||
*.sh text eol=lf
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Predictalytics
|
|
||||||
|
|
||||||
**Zuerst hier nachsehen:**
|
|
||||||
* [`STATUS.md`](STATUS.md) — wo das Projekt steht, was fertig ist.
|
|
||||||
* [`ROADMAP.md`](ROADMAP.md) — was ansteht, in welcher Reihenfolge, und welche Konzepte
|
|
||||||
bewusst zurückgestellt sind.
|
|
||||||
|
|
||||||
Die früheren Plandokumente liegen unter [`docs/archiv/`](docs/archiv/) und werden nicht
|
|
||||||
mehr gepflegt.
|
|
||||||
|
|
||||||
Betriebsdokumentation zur Anbindung an das Deployment Center (Lizenz, Watchdog,
|
|
||||||
Updates, Fehler-Stream): [`docs/BETRIEB-Deploymentcenter.md`](docs/BETRIEB-Deploymentcenter.md).
|
|
||||||
|
|
||||||
## Zentraler Bugtracker — Deployment Center
|
|
||||||
|
|
||||||
Erfasse unbehandelte Fehler, geplante Verbesserungen und Ideen im zentralen
|
|
||||||
Deployment Center. Basis-URL: `https://dc.mhdf.de`
|
|
||||||
|
|
||||||
### Zugang
|
|
||||||
Token steht in der Umgebungsvariable `DC_TOKEN`.
|
|
||||||
Header: `Authorization: Bearer $DC_TOKEN`
|
|
||||||
|
|
||||||
Ist `DC_TOKEN` nicht gesetzt, melde nichts und weise stattdessen darauf hin —
|
|
||||||
die Endpunkte antworten sonst mit `401 unauthorized`.
|
|
||||||
|
|
||||||
Die vollständige Schnittstellenbeschreibung liegt maschinenlesbar unter
|
|
||||||
`GET /api/openapi.json`, das Handbuch unter `/docs/`.
|
|
||||||
|
|
||||||
### Projekt
|
|
||||||
Dieses Projekt ist `predictalytics`.
|
|
||||||
Repo: `http://192.168.178.10:8418/Richard/Predictalytics`
|
|
||||||
Fällt dir ein Fehler im Deployment Center selbst auf, melde ihn unter
|
|
||||||
`deploymentcenter`.
|
|
||||||
|
|
||||||
### Etwas melden
|
|
||||||
`POST /api/bugtracker/v1/report`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"project_slug": "predictalytics",
|
|
||||||
"type": "bug",
|
|
||||||
"title": "Kurze, aussagekräftige Zusammenfassung",
|
|
||||||
"description": "Unter welchen Bedingungen tritt es auf?",
|
|
||||||
"error_message": "Exakte Fehlermeldung",
|
|
||||||
"stack_trace": "Vollständiger Stacktrace",
|
|
||||||
"severity": "high",
|
|
||||||
"environment": "production",
|
|
||||||
"build_version": "v1.0.0",
|
|
||||||
"repo_url": "http://192.168.178.10:8418/Richard/Predictalytics",
|
|
||||||
"git_branch": "main",
|
|
||||||
"commit_sha": "a21536f",
|
|
||||||
"file_path": "src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs",
|
|
||||||
"line_no": 152,
|
|
||||||
"client_ref": "eindeutige-id-dieses-laufs"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setze immer `client_ref`** — ein wiederholter Aufruf mit demselben Wert legt
|
|
||||||
kein Duplikat an. Gib nach Möglichkeit `file_path` und `line_no` an; das spart
|
|
||||||
dem nächsten Agenten das Parsen des Stacktrace.
|
|
||||||
|
|
||||||
Schweregrade: `idea` (Gedanke für später), `wishlist` (Backlog),
|
|
||||||
`low`, `medium`, `high`, `critical`.
|
|
||||||
|
|
||||||
### Arbeit übernehmen
|
|
||||||
Bevor du an einem Item arbeitest, übernimm es — sonst arbeiten zwei Agenten
|
|
||||||
parallel am selben Problem:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/bugtracker/v1/manage?action=next
|
|
||||||
Body: {"project_slug": "predictalytics", "limit": 1}
|
|
||||||
```
|
|
||||||
|
|
||||||
Antwortet der Server mit `409 already_claimed`, nimm das nächste Item.
|
|
||||||
Die Reservierung läuft nach 30 Minuten ab; `action=claim` auf dieselbe ID
|
|
||||||
erneuert sie.
|
|
||||||
|
|
||||||
### Fortschritt festhalten
|
|
||||||
```
|
|
||||||
POST /api/bugtracker/v1/manage?action=comment&id=<ID>
|
|
||||||
Body: {"comment": "Was du herausgefunden hast", "action_taken": "investigated"}
|
|
||||||
```
|
|
||||||
|
|
||||||
`action_taken`: `investigated`, `fix_proposed`, `pr_opened`, `needs_human`,
|
|
||||||
`blocked`, `commented`.
|
|
||||||
|
|
||||||
### Abschließen
|
|
||||||
```
|
|
||||||
POST /api/bugtracker/v1/manage?action=resolve&id=<ID>
|
|
||||||
Body: {"resolved_in_build": "v1.0.1", "resolution_notes": "Was geändert wurde"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Kommst du nicht weiter, gib das Item zurück statt es blockieren zu lassen:
|
|
||||||
```
|
|
||||||
POST /api/bugtracker/v1/manage?action=release&id=<ID>
|
|
||||||
Body: {"note": "Grund"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Laufzeitfehler
|
|
||||||
Die Anwendung meldet Error/Fatal selbst über `POST /api/errors/v1/report`
|
|
||||||
(`Services/DcErrorReporter.cs`) — dort nichts von Hand nachreichen.
|
|
||||||
|
|
||||||
### Release melden
|
|
||||||
Nach einem Release schließen sich Items mit passendem `resolved_in_build`
|
|
||||||
automatisch:
|
|
||||||
```
|
|
||||||
POST /api/updateservice/v1/publish
|
|
||||||
Body: {"product_slug": "predictalytics", "version": "1.0.1",
|
|
||||||
"download_url": "...", "sha256_hash": "...", "git_commit": "..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Version in `Directory.Build.props` (`<Version>`) muss dazu passen — sie ist
|
|
||||||
es, die der Client als installierte Version meldet.
|
|
||||||
|
|
||||||
### Fehlerbehandlung
|
|
||||||
Antworten haben die Form `{"status":"error","error":{"code":"…"}}`.
|
|
||||||
Reagiere auf `code`, nicht auf den Text:
|
|
||||||
- `401 unauthorized` — Token prüfen, nicht wiederholen
|
|
||||||
- `409 already_claimed` — nächstes Item nehmen
|
|
||||||
- `429 rate_limited` — Intervall verdoppeln, später erneut
|
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
|
|
||||||
dem UpdateService, Fehler-Stream und Heartbeat genutzt. -->
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
<!-- Zielframework zentral fuer alle Projekte. Seit die Avalonia-Shell den
|
|
||||||
WinForms-Host abgeloest hat, gibt es keine windows-spezifische Ausnahme mehr. -->
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
<!-- Die Anwendung wird nicht lokalisiert; lokalisierte Satelliten-Assemblies
|
|
||||||
der Abhaengigkeiten blaehen nur das Publish-Output auf. -->
|
|
||||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
<!-- Erlaubt, transitive Abhaengigkeiten hochzuziehen (siehe Sicherheits-Pins unten). -->
|
|
||||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- Sicherheits-Pins fuer transitive Abhaengigkeiten.
|
|
||||||
SQLitePCLRaw kommt ueber Microsoft.EntityFrameworkCore.Sqlite (nur Testprojekt).
|
|
||||||
EF Core 9.0.18 zieht 2.1.10, das GHSA-2m69-gcr7-jv3q (High) aufweist. -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
|
||||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
|
|
||||||
<PackageVersion Include="SQLitePCLRaw.core" Version="2.1.12" />
|
|
||||||
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="2.1.12" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════
|
|
||||||
Entity Framework Core — bewusst auf 9.x festgehalten.
|
|
||||||
|
|
||||||
Pomelo.EntityFrameworkCore.MySql hat keine EF-Core-10-Version; die
|
|
||||||
neueste stabile 9.0.0 pinnt Microsoft.EntityFrameworkCore.Relational
|
|
||||||
auf [9.0.0, 9.0.999] — eine harte Obergrenze. Die EF-9-Pakete zielen
|
|
||||||
auf net8.0 und laufen problemlos auf der net10.0-Runtime.
|
|
||||||
|
|
||||||
Vor einem Upgrade auf EF Core 10 zuerst pruefen, ob Pomelo nachgezogen
|
|
||||||
hat. Siehe docs/PLAN-Linux-Portierung.md, Abschnitt 0.1.
|
|
||||||
══════════════════════════════════════════════════════════════════════ -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.18" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.18" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.18" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.18" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.18" />
|
|
||||||
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Microsoft.Extensions — passend zur net10.0-Runtime -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Serilog -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="Serilog" Version="4.4.0" />
|
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
|
||||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
|
||||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- API-Dokumentation -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Avalonia — Bedienhuelle fuer Windows und Linux.
|
|
||||||
12.1.1 liefert native net10.0-Assemblies.
|
|
||||||
Avalonia.Diagnostics (DevTools) gibt es noch nicht fuer 12.x; die
|
|
||||||
Debug-Referenz bleibt daher auf der 11er-Linie und ist optional. -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="Avalonia" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
|
||||||
<!-- Mitgelieferte Schrift: Linux-Distributionen bringen sehr unterschiedliche
|
|
||||||
UI-Fonts mit, damit sieht die Oberflaeche ueberall gleich aus. -->
|
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Test -->
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
|
||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
|
||||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
# FIXPLAN Teil G — Speicher-Budget & Kategorie-Edge (für Gemini 3.5 Flash)
|
# FIXPLAN Teil G — Speicher-Budget & Kategorie-Edge (für Gemini 3.5 Flash)
|
||||||
|
|
||||||
> **✅ ABGESCHLOSSEN — G1 bis G4 sind umgesetzt.** Am 2026-08-23 gegen den Code geprüft:
|
|
||||||
> * **G1** Speicher-Governor — `Application/Services/StorageGovernor.cs`, angewendet in
|
|
||||||
> `TradeRetentionWorker.cs:89`; `MaxDatabaseSizeGb`/`MinRetentionDays` werden gelesen
|
|
||||||
> (`:74,75`). Tests in `StorageGovernorTests.cs` und `TradeRetentionWorkerTests.cs`.
|
|
||||||
> * **G2** Retention räumt auch `Aggregated`-Trader — `TradeRetentionWorker.cs`, Test vorhanden.
|
|
||||||
> * **G3** `UsdcSize` auf `Trade`, `OutcomeIndex` auf `MarketOutcome` — beide persistiert,
|
|
||||||
> Test in `TradeRepositoryTests.cs`.
|
|
||||||
> * **G4** Per-Kategorie-Edge — `AvgReturnPct` bis in die Trader-Detailansicht
|
|
||||||
> (`TraderEndpoints.cs:80`, `app.js:913`).
|
|
||||||
>
|
|
||||||
> Der **Anhang** („Einmalige DB-Optimierungen") bleibt offen: das sind SQL-Schritte, die
|
|
||||||
> laut Absprache der Nutzer selbst gegen die Datenbank fährt, kein Code.
|
|
||||||
>
|
|
||||||
> Die unten genannte Test-Baseline (39/1) ist überholt — Stand 2026-08-23 sind es
|
|
||||||
> **126 grün + 1 übersprungen**.
|
|
||||||
|
|
||||||
> Eigenständiger Plan. Von oben nach unten abarbeiten. Nach **jeder** Aufgabe bauen + testen + committen.
|
> Eigenständiger Plan. Von oben nach unten abarbeiten. Nach **jeder** Aufgabe bauen + testen + committen.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -147,7 +131,7 @@ In `src/Predictalytics.Worker/Services/TradeRetentionWorker.cs`, Methode `RunOpt
|
|||||||
|
|
||||||
### G1.4 — Config-Defaults
|
### G1.4 — Config-Defaults
|
||||||
|
|
||||||
In `src/Predictalytics.Api/appsettings.json` unter `RetentionSettings` (Objekt anlegen falls
|
In `src/Predictalytics.WinFormsHost/appsettings.json` unter `RetentionSettings` (Objekt anlegen falls
|
||||||
fehlt) ergänzen: `"MaxDatabaseSizeGb": 90, "MinRetentionDays": 30`. (Nur Defaults; nichts Bestehendes löschen.)
|
fehlt) ergänzen: `"MaxDatabaseSizeGb": 90, "MinRetentionDays": 30`. (Nur Defaults; nichts Bestehendes löschen.)
|
||||||
|
|
||||||
**G1 fertig, wenn:** neue Tests grün, alte grün, Build grün. Commit: „G1: storage governor (budget-based retention)".
|
**G1 fertig, wenn:** neue Tests grün, alte grün, Build grün. Commit: „G1: storage governor (budget-based retention)".
|
||||||
@@ -1,27 +1,9 @@
|
|||||||
# Fix- und Datenreparatur-Plan (Stand 2026-07-09, Übergabe an Gemini)
|
# Fix- und Datenreparatur-Plan (Stand 2026-07-09, Übergabe an Gemini)
|
||||||
|
|
||||||
> **Status 2026-08-23: Teil D und Teil F sind umgesetzt**, nachgeprüft gegen den Code.
|
> **Abnahmekriterium für alle Code-Änderungen:** `dotnet test src/Predictalytics.Application.Tests` muss
|
||||||
> Offen sind nur noch drei Detailpunkte, jeweils unten als `- [ ]` stehengeblieben:
|
> **16 grün + 1 übersprungen** liefern (der Skip `CheckpointResetAndReplay_DoesNotDoubleCountBalance` ist eine
|
||||||
> der gezielte Nachlade-Schritt in F5 und die zwei Tests aus F6 (Event-Tag-Vererbung,
|
> dokumentierte, bewusste Entscheidung). Die Assertions der Invarianten-Tests dürfen **nicht** verändert werden —
|
||||||
> Backfill). Belege:
|
> sie definieren das Soll-Verhalten. Wenn ein Test rot wird, ist der Code falsch, nicht der Test.
|
||||||
> * **D3** — `IngestMode` (`Domain/Enums/IngestMode.cs`), Einstufung in
|
|
||||||
> `TradeHistoryWorker.cs:174-177` inklusive der geforderten Hysterese
|
|
||||||
> (Rückstufung erst unter halber Schwelle, Richtung `Full` zusätzlich erst nach
|
|
||||||
> 7 Tagen), wöchentliche Biopsie in `TradeHistoryWorker.cs:127`,
|
|
||||||
> Aggregations-Buckets in `TradeAggregation.cs`, Trait `not_copyable_hf` in
|
|
||||||
> `TraderTraitCalculator.cs`. Tests: `IngestModeTests.cs`.
|
|
||||||
> * **F1/F2** — `CanonicalTags` und `BlacklistTags`/`IsNoiseTag` in
|
|
||||||
> `Infrastructure/Helpers/MarketCategoryMapper.cs`.
|
|
||||||
> * **F3/F4** — Tag-Nachladen in `PolymarketProvider.cs:187-196`, Offline-Backfill
|
|
||||||
> aus Event-Tags in `PredictalyticsHost.cs:114-135`.
|
|
||||||
>
|
|
||||||
> **Abnahmekriterium für alle Code-Änderungen:** `dotnet test` muss grün bleiben.
|
|
||||||
> Die damals notierte Zahl (16 grün + 1 übersprungen) ist überholt — Stand
|
|
||||||
> 2026-08-23 sind es **126 grün + 1 übersprungen** (der Skip
|
|
||||||
> `CheckpointResetAndReplay_DoesNotDoubleCountBalance` ist weiterhin eine
|
|
||||||
> dokumentierte, bewusste Entscheidung). Die Assertions der Invarianten-Tests dürfen
|
|
||||||
> **nicht** verändert werden — sie definieren das Soll-Verhalten. Wenn ein Test rot
|
|
||||||
> wird, ist der Code falsch, nicht der Test.
|
|
||||||
|
|
||||||
## Hintergrund
|
## Hintergrund
|
||||||
|
|
||||||
@@ -113,35 +95,35 @@ Reihenfolge: **D1 → D2/D2b/D2c → D3** (D2c ist klein und gehört in denselbe
|
|||||||
|
|
||||||
### F1. Mapper: kanonischen Tag zuerst, dann Heuristik
|
### F1. Mapper: kanonischen Tag zuerst, dann Heuristik
|
||||||
|
|
||||||
- [x] In `MarketCategoryMapper.Map`: **zuerst** prüfen, ob **irgendein Tag exakt** einer bekannten
|
- [ ] In `MarketCategoryMapper.Map`: **zuerst** prüfen, ob **irgendein Tag exakt** einer bekannten
|
||||||
Kategorie entspricht (Polymarkets Tag-Vokabular enthält fast immer den kanonischen Top-Level-Tag:
|
Kategorie entspricht (Polymarkets Tag-Vokabular enthält fast immer den kanonischen Top-Level-Tag:
|
||||||
`Sports`, `Politics`, `Crypto`, `Business`/`Economy`, `Pop Culture`, `Science`, ...). Mapping-Tabelle
|
`Sports`, `Politics`, `Crypto`, `Business`/`Economy`, `Pop Culture`, `Science`, ...). Mapping-Tabelle
|
||||||
Tag→`MarketCategory` (inkl. Synonyme: `Finance`/`Business`→Economy, `Pop Culture`→PopCulture).
|
Tag→`MarketCategory` (inkl. Synonyme: `Finance`/`Business`→Economy, `Pop Culture`→PopCulture).
|
||||||
Erst wenn **kein** kanonischer Tag matcht, die bestehende Keyword-Heuristik auf Frage+Tags anwenden.
|
Erst wenn **kein** kanonischer Tag matcht, die bestehende Keyword-Heuristik auf Frage+Tags anwenden.
|
||||||
- [x] **Kategorie über die gesamte Tag-Menge** bestimmen, nie über `tags[0]`.
|
- [ ] **Kategorie über die gesamte Tag-Menge** bestimmen, nie über `tags[0]`.
|
||||||
|
|
||||||
### F2. Subcategory: Noise filtern, sinnvoll wählen, leer normalisieren
|
### F2. Subcategory: Noise filtern, sinnvoll wählen, leer normalisieren
|
||||||
|
|
||||||
- [x] Organisations-/Müll-Tags herausfiltern (Blacklist: `Hide From New`, `Tournament Futures`,
|
- [ ] Organisations-/Müll-Tags herausfiltern (Blacklist: `Hide From New`, `Tournament Futures`,
|
||||||
`Main Election`, `Recurring`, Jahres-Tags wie `2025 Predictions`, `2026 FIFA World Cup`→ok als Sub?, …).
|
`Main Election`, `Recurring`, Jahres-Tags wie `2025 Predictions`, `2026 FIFA World Cup`→ok als Sub?, …).
|
||||||
- [x] Subcategory = spezifischster **verbleibender** Tag, der **nicht** die Kategorie selbst ist
|
- [ ] Subcategory = spezifischster **verbleibender** Tag, der **nicht** die Kategorie selbst ist
|
||||||
(bei World-Cup-Tags → `Soccer`, nicht `Sports`). Kein passender → leerer String.
|
(bei World-Cup-Tags → `Soccer`, nicht `Sports`). Kein passender → leerer String.
|
||||||
- [x] **NULL/`""` einheitlich als `""`** speichern (behebt die doppelten „Sports/-"-Zeilen: heute
|
- [ ] **NULL/`""` einheitlich als `""`** speichern (behebt die doppelten „Sports/-"-Zeilen: heute
|
||||||
entstehen zwei Gruppen-Keys aus NULL vs. "").
|
entstehen zwei Gruppen-Keys aus NULL vs. "").
|
||||||
|
|
||||||
### F3. On-Demand-Markt: Tags nachladen statt `Other` zu speichern
|
### F3. On-Demand-Markt: Tags nachladen statt `Other` zu speichern
|
||||||
|
|
||||||
- [x] In `GetMarketAsync`: wenn `parentTags` leer ist, aber ein Event mit Id vorhanden ist →
|
- [ ] In `GetMarketAsync`: wenn `parentTags` leer ist, aber ein Event mit Id vorhanden ist →
|
||||||
**`/events?id=<eventId>` nachladen** (liefert Tags, 1 Extra-Call pro neuem Markt, cachebar) und die
|
**`/events?id=<eventId>` nachladen** (liefert Tags, 1 Extra-Call pro neuem Markt, cachebar) und die
|
||||||
Tags fürs Mapping verwenden. Über den `IRateLimiter` drosseln.
|
Tags fürs Mapping verwenden. Über den `IRateLimiter` drosseln.
|
||||||
- [x] Alternativ/zusätzlich: existiert das Parent-Event bereits in unserer DB (aus dem Events-Sync,
|
- [ ] Alternativ/zusätzlich: existiert das Parent-Event bereits in unserer DB (aus dem Events-Sync,
|
||||||
`Event.Tags` gefüllt) → **Tags von dort erben**, ganz ohne API-Call.
|
`Event.Tags` gefüllt) → **Tags von dort erben**, ganz ohne API-Call.
|
||||||
|
|
||||||
### F4. Kategorie aus Event-Tags ableiten + Offline-Backfill (der große Hebel)
|
### F4. Kategorie aus Event-Tags ableiten + Offline-Backfill (der große Hebel)
|
||||||
|
|
||||||
- [x] Markt-Kategorie primär aus den **Event-Tags** (`market.Event.Tags`) ableiten, nicht aus den
|
- [ ] Markt-Kategorie primär aus den **Event-Tags** (`market.Event.Tags`) ableiten, nicht aus den
|
||||||
(leeren) Markt-Feldern. On-Demand-Märkte erben so die Kategorie ihres Events.
|
(leeren) Markt-Feldern. On-Demand-Märkte erben so die Kategorie ihres Events.
|
||||||
- [x] **Einmaliger Offline-Backfill** (keine API-Calls!): über alle Märkte iterieren, deren Event
|
- [ ] **Einmaliger Offline-Backfill** (keine API-Calls!): über alle Märkte iterieren, deren Event
|
||||||
Tags hat, und Kategorie/Subcategory aus `Event.Tags` neu ableiten (mit F1/F2). Als Methode in
|
Tags hat, und Kategorie/Subcategory aus `Event.Tags` neu ableiten (mit F1/F2). Als Methode in
|
||||||
`RunRecalculateAllTradersAsync` einhängen ODER eigener Dev-Endpoint. Danach `TraderCategoryPerformance`
|
`RunRecalculateAllTradersAsync` einhängen ODER eigener Dev-Endpoint. Danach `TraderCategoryPerformance`
|
||||||
neu rechnen (passiert durch die ohnehin folgende Trader-Neuberechnung).
|
neu rechnen (passiert durch die ohnehin folgende Trader-Neuberechnung).
|
||||||
@@ -154,9 +136,9 @@ Reihenfolge: **D1 → D2/D2b/D2c → D3** (D2c ist klein und gehört in denselbe
|
|||||||
|
|
||||||
### F6. Tests
|
### F6. Tests
|
||||||
|
|
||||||
- [x] Mapper: kanonischer Tag gewinnt über Reihenfolge (`"Ethiopia, Elections, ..., Politics"` → Politics;
|
- [ ] Mapper: kanonischer Tag gewinnt über Reihenfolge (`"Ethiopia, Elections, ..., Politics"` → Politics;
|
||||||
`"Sports, Soccer, ..."` → Sports, Subcategory `Soccer`).
|
`"Sports, Soccer, ..."` → Sports, Subcategory `Soccer`).
|
||||||
- [x] Subcategory: Müll-Tags werden gefiltert; NULL und `""` erzeugen denselben Gruppen-Key
|
- [ ] Subcategory: Müll-Tags werden gefiltert; NULL und `""` erzeugen denselben Gruppen-Key
|
||||||
(kein Duplikat mehr).
|
(kein Duplikat mehr).
|
||||||
- [ ] Event-Tag-Vererbung: Markt ohne eigene Tags, Event mit `['Crypto',...]` → Markt wird Crypto.
|
- [ ] Event-Tag-Vererbung: Markt ohne eigene Tags, Event mit `['Crypto',...]` → Markt wird Crypto.
|
||||||
- [ ] Backfill: Markt in DB als `Other`, Event.Tags = `['Politics',...]` → nach Backfill Politics,
|
- [ ] Backfill: Markt in DB als `Other`, Event.Tags = `['Politics',...]` → nach Backfill Politics,
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
# FIXPLAN Teil UI — Ranglisten & Showcases sichtbar/korrekt machen
|
# FIXPLAN Teil UI — Ranglisten & Showcases sichtbar/korrekt machen
|
||||||
|
|
||||||
> **✅ ABGESCHLOSSEN.** Umgesetzt in den Commits *„UI-U3: server-side
|
|
||||||
> min-winrate/min-copyability filters"* und *„UI-U1/U2/U4/U5: dashboard showcases +
|
|
||||||
> server-side leaderboard sort"*, beide in `feature/linux-net10` enthalten.
|
|
||||||
> Am 2026-08-23 Punkt für Punkt gegen den Code nachgeprüft: `loadShowcases()`
|
|
||||||
> (`app.js:393`) wird aus `loadDashboard()` heraus aufgerufen (`app.js:307`),
|
|
||||||
> Red Flags heben sich über `showcase-card--warn` ab, `take` steht auf 200 mit
|
|
||||||
> serverseitigem `sort`-Parameter (`app.js:466,470`), `minWinRate`/`minCopyability`
|
|
||||||
> gehen bis in `TraderEndpoints.cs:16` durch (Test in `AnalyticsServiceTests.cs:135`),
|
|
||||||
> `#tradersSort` führt alle sieben Metriken, und der Empty-State steht auf
|
|
||||||
> `colspan="10"`.
|
|
||||||
>
|
|
||||||
> **Offen bleibt nur der als „Optional" markierte Punkt in U5** (Pfeilrichtung
|
|
||||||
> ▲/▼ in der aktiven Sortierspalte) — die Tabellenköpfe zeigen weiterhin
|
|
||||||
> statisches ↕. Reine Politur, kein Fehlverhalten.
|
|
||||||
|
|
||||||
> Eigenständiger Plan für die Web-UI (`src/Predictalytics.Api/wwwroot/`). Von oben nach unten
|
> Eigenständiger Plan für die Web-UI (`src/Predictalytics.Api/wwwroot/`). Von oben nach unten
|
||||||
> abarbeiten. Nach **jeder** Aufgabe im Browser gegen die laufende API prüfen und committen.
|
> abarbeiten. Nach **jeder** Aufgabe im Browser gegen die laufende API prüfen und committen.
|
||||||
|
|
||||||
@@ -61,10 +46,10 @@ aber das **Frontend nicht nachgezogen**:
|
|||||||
**Ziel:** Die 7 kuratierten Sektionen sichtbar machen — je Sektion Titel, Beschreibung und die Top-Trader
|
**Ziel:** Die 7 kuratierten Sektionen sichtbar machen — je Sektion Titel, Beschreibung und die Top-Trader
|
||||||
als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`).
|
als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`).
|
||||||
|
|
||||||
- [x] **HTML:** In `index.html` im `#page-dashboard` einen Container `<div id="showcaseSections"></div>`
|
- [ ] **HTML:** In `index.html` im `#page-dashboard` einen Container `<div id="showcaseSections"></div>`
|
||||||
ergänzen (unter den bestehenden Dashboard-Karten, vor/nach „Top Traders" — Platzierung so, dass es
|
ergänzen (unter den bestehenden Dashboard-Karten, vor/nach „Top Traders" — Platzierung so, dass es
|
||||||
nicht mit den bestehenden Kacheln kollidiert).
|
nicht mit den bestehenden Kacheln kollidiert).
|
||||||
- [x] **JS:** Neue Funktion `loadShowcases()` in `app.js`:
|
- [ ] **JS:** Neue Funktion `loadShowcases()` in `app.js`:
|
||||||
- `const sections = await api('/api/traders/showcases');`
|
- `const sections = await api('/api/traders/showcases');`
|
||||||
- Response-Shape (camelCase): `[{ key, title, description, traders: [TraderDto, ...] }]`.
|
- Response-Shape (camelCase): `[{ key, title, description, traders: [TraderDto, ...] }]`.
|
||||||
- Pro Sektion eine `card` rendern: `title` als Überschrift, `description` als `page-subtitle`-artiger
|
- Pro Sektion eine `card` rendern: `title` als Überschrift, `description` als `page-subtitle`-artiger
|
||||||
@@ -74,8 +59,8 @@ als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`).
|
|||||||
- Leere Sektionen liefert das Backend gar nicht erst — kein Sonderfall nötig; aber wenn die ganze
|
- Leere Sektionen liefert das Backend gar nicht erst — kein Sonderfall nötig; aber wenn die ganze
|
||||||
Antwort leer/`null` ist, freundlicher Empty-State.
|
Antwort leer/`null` ist, freundlicher Empty-State.
|
||||||
- Jede Trader-Zeile: `onclick="viewTrader(${t.id})"`.
|
- Jede Trader-Zeile: `onclick="viewTrader(${t.id})"`.
|
||||||
- [x] `loadShowcases()` in `loadDashboard()` aufrufen (bzw. beim Aktivieren von `page-dashboard`).
|
- [ ] `loadShowcases()` in `loadDashboard()` aufrufen (bzw. beim Aktivieren von `page-dashboard`).
|
||||||
- [x] **Optik:** `insider_watch` und `red_flags` optisch abheben (z. B. Warn-Akzent für Red Flags über
|
- [ ] **Optik:** `insider_watch` und `red_flags` optisch abheben (z. B. Warn-Akzent für Red Flags über
|
||||||
vorhandene CSS-Variablen `--pnl-negative`), aber im bestehenden Kartenstil bleiben.
|
vorhandene CSS-Variablen `--pnl-negative`), aber im bestehenden Kartenstil bleiben.
|
||||||
|
|
||||||
**U1 fertig, wenn:** Dashboard zeigt die vom Backend gelieferten Sektionen; Klick auf einen Trader öffnet
|
**U1 fertig, wenn:** Dashboard zeigt die vom Backend gelieferten Sektionen; Klick auf einen Trader öffnet
|
||||||
@@ -88,16 +73,16 @@ die Detailseite; keine Konsolenfehler. Commit: „UI-U1: render dashboard showca
|
|||||||
**Ziel:** Die Rangliste zeigt die **echten** Top-N nach der gewählten Metrik, nicht nur eine Umsortierung
|
**Ziel:** Die Rangliste zeigt die **echten** Top-N nach der gewählten Metrik, nicht nur eine Umsortierung
|
||||||
der ersten 100.
|
der ersten 100.
|
||||||
|
|
||||||
- [x] **JS:** In `loadTraders()` den gewählten Sort als Query-Param an die API hängen:
|
- [ ] **JS:** In `loadTraders()` den gewählten Sort als Query-Param an die API hängen:
|
||||||
`url += '&sort=' + encodeURIComponent(serverSortKey)`. Mapping UI→Server:
|
`url += '&sort=' + encodeURIComponent(serverSortKey)`. Mapping UI→Server:
|
||||||
`score→` (leer/default), `winrate→winrate`, `copyability→copytrading`, `pnl→pnl`,
|
`score→` (leer/default), `winrate→winrate`, `copyability→copytrading`, `pnl→pnl`,
|
||||||
`pnl30d→pnl30d`, `calmar→calmar`, `conviction→conviction`, `profitfactor→profitfactor`.
|
`pnl30d→pnl30d`, `calmar→calmar`, `conviction→conviction`, `profitfactor→profitfactor`.
|
||||||
(Der Server ordnet absteigend — das ist für alle diese Metriken die sinnvolle Richtung.)
|
(Der Server ordnet absteigend — das ist für alle diese Metriken die sinnvolle Richtung.)
|
||||||
- [x] **JS:** Den clientseitigen `data.sort(...)`-Block in `loadTraders()` entfernen bzw. nur noch als
|
- [ ] **JS:** Den clientseitigen `data.sort(...)`-Block in `loadTraders()` entfernen bzw. nur noch als
|
||||||
Fallback für die rein clientseitigen Keys `name`/`platform` behalten (die kennt der Server nicht).
|
Fallback für die rein clientseitigen Keys `name`/`platform` behalten (die kennt der Server nicht).
|
||||||
- [x] **take** von 100 auf einen sinnvollen Wert erhöhen (z. B. 200) — die serverseitige Sortierung liefert
|
- [ ] **take** von 100 auf einen sinnvollen Wert erhöhen (z. B. 200) — die serverseitige Sortierung liefert
|
||||||
jetzt die richtige Reihenfolge, die Tabelle kann eine echte Bestenliste zeigen.
|
jetzt die richtige Reihenfolge, die Tabelle kann eine echte Bestenliste zeigen.
|
||||||
- [x] Die Klick-Sortierung der Tabellenköpfe (`setTraderSort`, `app.js:376`) auf denselben Pfad umstellen:
|
- [ ] Die Klick-Sortierung der Tabellenköpfe (`setTraderSort`, `app.js:376`) auf denselben Pfad umstellen:
|
||||||
Sort setzen → `loadTraders()` (das nun serverseitig sortiert). Die `↕`-Header behalten.
|
Sort setzen → `loadTraders()` (das nun serverseitig sortiert). Die `↕`-Header behalten.
|
||||||
|
|
||||||
**U2 fertig, wenn:** Auswahl „Calmar/Conviction/Profit-Faktor" (nach U4) verändert die Reihenfolge sichtbar
|
**U2 fertig, wenn:** Auswahl „Calmar/Conviction/Profit-Faktor" (nach U4) verändert die Reihenfolge sichtbar
|
||||||
@@ -109,16 +94,16 @@ und zeigt Trader, die vorher nicht in den ersten 100 waren. Commit: „UI-U2: se
|
|||||||
|
|
||||||
**Ziel:** Die Kennzahl-Mindestfilter dürfen nicht an der 100/200-Grenze abschneiden.
|
**Ziel:** Die Kennzahl-Mindestfilter dürfen nicht an der 100/200-Grenze abschneiden.
|
||||||
|
|
||||||
- [x] **API:** `IAnalyticsService.GetTradersAsync` und die Implementierung (`AnalyticsService.cs:165`) um
|
- [ ] **API:** `IAnalyticsService.GetTradersAsync` und die Implementierung (`AnalyticsService.cs:165`) um
|
||||||
zwei **optionale nullable** Parameter erweitern: `decimal? minWinRate = null,
|
zwei **optionale nullable** Parameter erweitern: `decimal? minWinRate = null,
|
||||||
decimal? minCopyability = null`. In der In-Memory-Filterkette (dort wird ohnehin schon `highlyCopyable`
|
decimal? minCopyability = null`. In der In-Memory-Filterkette (dort wird ohnehin schon `highlyCopyable`
|
||||||
und `traitFilter` gefiltert) anwenden:
|
und `traitFilter` gefiltert) anwenden:
|
||||||
`if (minWinRate is > 0) traders = traders.Where(t => t.WinRate >= minWinRate).ToList();` und analog
|
`if (minWinRate is > 0) traders = traders.Where(t => t.WinRate >= minWinRate).ToList();` und analog
|
||||||
`t.Analytics?.CopytradingCopyabilityScore >= minCopyability`.
|
`t.Analytics?.CopytradingCopyabilityScore >= minCopyability`.
|
||||||
- [x] **Endpoint:** In `TraderEndpoints.cs` die `/api/traders`-Route um die zwei Query-Parameter durchreichen.
|
- [ ] **Endpoint:** In `TraderEndpoints.cs` die `/api/traders`-Route um die zwei Query-Parameter durchreichen.
|
||||||
- [x] **JS:** In `loadTraders()` `filterWinrateMin`/`filterCopyabilityMin` als Query-Param senden statt
|
- [ ] **JS:** In `loadTraders()` `filterWinrateMin`/`filterCopyabilityMin` als Query-Param senden statt
|
||||||
clientseitig zu filtern; den clientseitigen Filter-Block entfernen.
|
clientseitig zu filtern; den clientseitigen Filter-Block entfernen.
|
||||||
- [x] **Test:** Kleiner Service-Test (SQLite-Muster wie vorhandene `AnalyticsService`/Repository-Tests):
|
- [ ] **Test:** Kleiner Service-Test (SQLite-Muster wie vorhandene `AnalyticsService`/Repository-Tests):
|
||||||
drei Trader mit WinRate 40/60/80, `minWinRate=55` → nur zwei zurück. Baseline bleibt grün.
|
drei Trader mit WinRate 40/60/80, `minWinRate=55` → nur zwei zurück. Baseline bleibt grün.
|
||||||
|
|
||||||
**U3 fertig, wenn:** Ein hoher Mindest-Copyability-Wert zeigt auch Trader, die in der Default-Order weit
|
**U3 fertig, wenn:** Ein hoher Mindest-Copyability-Wert zeigt auch Trader, die in der Default-Order weit
|
||||||
@@ -130,13 +115,13 @@ hinten stehen. Commit: „UI-U3: server-side min-winrate/min-copyability filters
|
|||||||
|
|
||||||
**Ziel:** Ein einziges, vollständiges Sort-Steuerelement für die Rangliste.
|
**Ziel:** Ein einziges, vollständiges Sort-Steuerelement für die Rangliste.
|
||||||
|
|
||||||
- [x] **HTML:** `#tradersSort` (`index.html:209`) um die aussagekräftigen Metriken erweitern:
|
- [ ] **HTML:** `#tradersSort` (`index.html:209`) um die aussagekräftigen Metriken erweitern:
|
||||||
`pnl30d` (PnL 30 T), `calmar` (Rendite/Drawdown), `conviction` (Conviction-Edge),
|
`pnl30d` (PnL 30 T), `calmar` (Rendite/Drawdown), `conviction` (Conviction-Edge),
|
||||||
`profitfactor` (Profit-Faktor), `copyability` (bereits da), `score`/`winrate`/`pnl`/`name` behalten.
|
`profitfactor` (Profit-Faktor), `copyability` (bereits da), `score`/`winrate`/`pnl`/`name` behalten.
|
||||||
- [x] **JS:** Klarstellen, welches Dropdown die Traders-Seite steuert. `#tradersSort` bleibt maßgeblich für
|
- [ ] **JS:** Klarstellen, welches Dropdown die Traders-Seite steuert. `#tradersSort` bleibt maßgeblich für
|
||||||
`page-traders`; das globale `#sortSelect` (falls es die Traders-Seite mitsteuert) entkoppeln oder
|
`page-traders`; das globale `#sortSelect` (falls es die Traders-Seite mitsteuert) entkoppeln oder
|
||||||
auf denselben State spiegeln, sodass es **kein** widersprüchliches `currentSort` mehr gibt.
|
auf denselben State spiegeln, sodass es **kein** widersprüchliches `currentSort` mehr gibt.
|
||||||
- [x] Sicherstellen, dass Header-Klick (`setTraderSort`) und Dropdown denselben `currentSort` schreiben und
|
- [ ] Sicherstellen, dass Header-Klick (`setTraderSort`) und Dropdown denselben `currentSort` schreiben und
|
||||||
beide `loadTraders()` (serverseitig, U2) auslösen.
|
beide `loadTraders()` (serverseitig, U2) auslösen.
|
||||||
|
|
||||||
**U4 fertig, wenn:** Alle Dropdown-Optionen sortieren korrekt (serverseitig), Header-Klick und Dropdown
|
**U4 fertig, wenn:** Alle Dropdown-Optionen sortieren korrekt (serverseitig), Header-Klick und Dropdown
|
||||||
@@ -146,9 +131,9 @@ bleiben synchron. Commit: „UI-U4: unify + extend leaderboard sort control".
|
|||||||
|
|
||||||
## Aufgabe U5 — Kleinkram / Konsistenz
|
## Aufgabe U5 — Kleinkram / Konsistenz
|
||||||
|
|
||||||
- [x] **colspan-Fix:** Empty-State in `loadTraders()` (`app.js:407`) von `colspan="11"` auf die echte
|
- [ ] **colspan-Fix:** Empty-State in `loadTraders()` (`app.js:407`) von `colspan="11"` auf die echte
|
||||||
Spaltenzahl **10** korrigieren.
|
Spaltenzahl **10** korrigieren.
|
||||||
- [x] **Spalten-Parität:** Prüfen, dass Tabellenkopf (`index.html:270`) und Zeilen-Template
|
- [ ] **Spalten-Parität:** Prüfen, dass Tabellenkopf (`index.html:270`) und Zeilen-Template
|
||||||
(`app.js:435`) dieselbe Spaltenzahl/-reihenfolge haben.
|
(`app.js:435`) dieselbe Spaltenzahl/-reihenfolge haben.
|
||||||
- [ ] **Optional:** Aktive Sortierspalte im Header visuell markieren (Pfeilrichtung ▲/▼ statt neutralem ↕).
|
- [ ] **Optional:** Aktive Sortierspalte im Header visuell markieren (Pfeilrichtung ▲/▼ statt neutralem ↕).
|
||||||
|
|
||||||
Binary file not shown.
+1
-2
@@ -4,9 +4,8 @@
|
|||||||
<Project Path="src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj" />
|
<Project Path="src/Predictalytics.Application.Tests/Predictalytics.Application.Tests.csproj" />
|
||||||
<Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" />
|
<Project Path="src/Predictalytics.Application/Predictalytics.Application.csproj" />
|
||||||
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
<Project Path="src/Predictalytics.Domain/Predictalytics.Domain.csproj" />
|
||||||
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.csproj" />
|
|
||||||
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
<Project Path="src/Predictalytics.Infrastructure/Predictalytics.Infrastructure.csproj" />
|
||||||
<Project Path="src/Predictalytics.Shell/Predictalytics.Shell.csproj" />
|
<Project Path="src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj" />
|
||||||
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
<Project Path="src/Predictalytics.Worker/Predictalytics.Worker.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
-345
@@ -1,345 +0,0 @@
|
|||||||
# Predictalytics — Roadmap
|
|
||||||
|
|
||||||
> Angelegt am **2026-08-23**. Führt zusammen, was vorher über neun Plandokumente verstreut
|
|
||||||
> lag. Jeder Punkt hier ist gegen den Code geprüft — was schon umgesetzt war, steht nicht
|
|
||||||
> mehr drin.
|
|
||||||
>
|
|
||||||
> **Ist-Stand des Projekts:** [`STATUS.md`](STATUS.md).
|
|
||||||
> **Die alten Dokumente** liegen vollständig unter [`docs/archiv/`](docs/archiv/) und
|
|
||||||
> werden nicht mehr gepflegt. Sie bleiben als Begründungs- und Detailquelle: wo ein Punkt
|
|
||||||
> unten knapp bleibt, steht die ausführliche Herleitung dort. Die Verweise sind jeweils
|
|
||||||
> angegeben.
|
|
||||||
|
|
||||||
## Fortschritt
|
|
||||||
|
|
||||||
| Stufe | Stand |
|
|
||||||
|---|---|
|
|
||||||
| 0 — Sofort | ✅ erledigt (2026-08-24) |
|
|
||||||
| 1 — Aufräumen abschließen | ✅ erledigt (2026-08-24) |
|
|
||||||
| 2 — Portierung abschließen | offen — **braucht ein Linux-System**, sonst nicht prüfbar |
|
|
||||||
| 3 — Analytik schärfen | offen, 3.1 ist der nächste inhaltliche Schritt |
|
|
||||||
| 4 — Datenhaushalt | offen, liegt beim Nutzer (SQL) |
|
|
||||||
| 5 — Ingest skalieren | offen, erst bei Bedarf |
|
|
||||||
| 6 — Monetarisierung | Konzept |
|
|
||||||
|
|
||||||
## Legende
|
|
||||||
|
|
||||||
| | Bedeutung |
|
|
||||||
|---|---|
|
|
||||||
| 🔴 | Dringend — sollte vor allem anderen passieren |
|
|
||||||
| 🟢 | Eingeplant — wird als Nächstes abgearbeitet |
|
|
||||||
| 🟡 | Backlog — gewollt, aber noch nicht terminiert |
|
|
||||||
| 💡 | **Konzept — bewusst nicht eingeplant.** Idee ist durchdacht und aufgehoben, die Umsetzung ist nicht beschlossen |
|
|
||||||
| 👤 | Liegt beim Nutzer, nicht am Code (SQL, Kontoverwaltung, Entscheidungen) |
|
|
||||||
| ❌ | Bewusst verworfen — nicht wieder aufgreifen ohne neuen Anlass |
|
|
||||||
|
|
||||||
Innerhalb der Stufen ist die Reihenfolge gedacht, aber nicht bindend. Zwischen den Stufen
|
|
||||||
schon: jede baut auf der vorigen auf.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 0 — Sofort
|
|
||||||
|
|
||||||
### 0.1 ✅ OpenRouter-API-Key aus dem Repository entfernt
|
|
||||||
|
|
||||||
*Erledigt am 2026-08-24.* Der Schlüssel war laut Nutzer bereits inaktiv und wurde deshalb
|
|
||||||
ersatzlos aus `src/Predictalytics.Api/appsettings.json` entfernt — eine Rotation war nicht
|
|
||||||
mehr nötig.
|
|
||||||
|
|
||||||
**Für den Ersatz:** den neuen Schlüssel **nicht** in die Datei schreiben, sondern als
|
|
||||||
Umgebungsvariable `OpenRouter__ApiKey` setzen. `WebApplication.CreateBuilder` liest
|
|
||||||
Umgebungsvariablen mit Vorrang vor `appsettings.json`, eine Codeänderung braucht es nicht.
|
|
||||||
Die Datei trägt jetzt einen entsprechenden Hinweis neben dem leeren Feld.
|
|
||||||
|
|
||||||
Ohne Schlüssel läuft die Anwendung sauber weiter: `OpenRouterApiClient` sendet gar nicht
|
|
||||||
erst und meldet den Zustand einmalig als Warnung. Vorher hätte jeder Analyseversuch einen
|
|
||||||
401 erzeugt, der als Fehler im Deployment Center gelandet wäre und dessen Fehlertext der
|
|
||||||
Aufrufer anschließend als JSON zu lesen versucht hätte.
|
|
||||||
|
|
||||||
> Der alte Schlüssel bleibt im Git-Verlauf (ab Commit `7045002`). Das ist hier folgenlos,
|
|
||||||
> weil er inaktiv ist — bei einem aktiven Schlüssel wäre die Rotation beim Anbieter der
|
|
||||||
> einzige wirksame Schritt gewesen.
|
|
||||||
>
|
|
||||||
> Herkunft: [`archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) B2 — dort noch als
|
|
||||||
> „kein akuter Risikofall" eingestuft, was sich als falsch herausgestellt hat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 1 — Aufräumen abschließen
|
|
||||||
|
|
||||||
### 1.1 ✅ Nach `main` gebracht, Feature-Zweige entfernt
|
|
||||||
|
|
||||||
*Erledigt am 2026-08-24.* `main` war als Fast-Forward auf `feature/linux-net10` zu ziehen,
|
|
||||||
beide stehen jetzt auf `402c372`. Vor dem Push gegen einen vollständigen Rebuild geprüft:
|
|
||||||
warnungsfrei, 126 Tests grün.
|
|
||||||
|
|
||||||
Die zehn Feature-Zweige sind lokal und auf origin gelöscht. Neun waren restlos in `main`
|
|
||||||
enthalten. Der zehnte, `fix-event-description-truncation`, war es nur inhaltlich — sein Fix
|
|
||||||
und sein Regressionstest stecken seit `cb58e09` in `main`, der Commit selbst wurde nie
|
|
||||||
gemergt. Er ist als Tag **`archiv/fix-event-description-truncation`** (`8a8a353`)
|
|
||||||
festgehalten, damit das Löschen nichts vernichtet.
|
|
||||||
|
|
||||||
`feature/linux-net10` bleibt vorerst bestehen und ist mit `main` identisch. Sobald klar
|
|
||||||
ist, dass die Arbeit auf `main` weiterläuft, kann auch dieser Zweig weg.
|
|
||||||
|
|
||||||
### 1.2 ✅ Nullable-Warnungen behoben — der Build ist warnungsfrei
|
|
||||||
|
|
||||||
*Erledigt am 2026-08-24.* Alle acht waren Annotationsfragen, keine echten Fehler; das
|
|
||||||
Laufzeitverhalten ist unverändert:
|
|
||||||
|
|
||||||
* `TradeRepository.cs` (6×) — die Parameterliste nimmt bewusst `null` auf, damit nullable
|
|
||||||
Spalten als SQL-NULL geschrieben werden. Jetzt als `List<object?>` deklariert, statt an
|
|
||||||
sechs Stellen zu unterdrücken.
|
|
||||||
* `TraderAnalyticsWorker.cs:152` — `ThenInclude(o => o!.Market)`. Der Lambda ist ein
|
|
||||||
Ausdrucksbaum für EF und wird nie ausgeführt.
|
|
||||||
* `PolymarketProvider.cs:357` — `raw.Question ?? ""`. Das Feld wurde ohnehin einen Schritt
|
|
||||||
später von `TruncateMarketStrings` auf `""` normalisiert.
|
|
||||||
|
|
||||||
**Der Build sollte warnungsfrei bleiben.** Eine neue Warnung fällt nur auf, solange es
|
|
||||||
keine alten gibt.
|
|
||||||
|
|
||||||
### 1.3 ✅ Kategorie-Backfill lädt nicht mehr den ganzen Marktbestand
|
|
||||||
|
|
||||||
*Erledigt am 2026-08-24.* Der Backfill in `RunRecalculateAllTradersAsync` holte alle Märkte
|
|
||||||
samt Event in den Speicher und verwarf den Großteil sofort wieder. Die Einschränkung auf
|
|
||||||
Märkte mit gefüllten Event-Tags steht jetzt in der Abfrage statt in der Schleife; das
|
|
||||||
Ergebnis ist unverändert.
|
|
||||||
|
|
||||||
> **Korrektur zur ersten Fassung dieser Roadmap:** dort stand, das laufe „bei jedem Start".
|
|
||||||
> Das war falsch — es ist die manuelle Wartungsaktion *Recalculate All Traders*, die nur
|
|
||||||
> auf Knopfdruck in der Shell läuft. Die Startzeit war nie betroffen, das Speicherrisiko
|
|
||||||
> bei der Aktion selbst schon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 2 — Portierung abschließen
|
|
||||||
|
|
||||||
Der Code ist plattformneutral gebaut, aber **auf keinem Linux-System je ausgeführt worden**.
|
|
||||||
Bis das nachgeholt ist, ist „läuft unter Linux" eine Annahme.
|
|
||||||
|
|
||||||
> Details zu allen Punkten: [`docs/archiv/PLAN-Linux-Portierung.md`](docs/archiv/PLAN-Linux-Portierung.md),
|
|
||||||
> Abschnitte 7 und 8.
|
|
||||||
|
|
||||||
### 2.1 🟢 Erstlauf und Verifikation unter Linux
|
|
||||||
|
|
||||||
Die Reihenfolge ist bewusst so: erst beweisen, dass es läuft, dann Deployment bauen.
|
|
||||||
|
|
||||||
- [ ] Shell auf einem Linux-Desktop starten — alle Schaltflächen, Menüs, Persistenz der Einstellungen
|
|
||||||
- [ ] Derselbe Build mit `--headless` auf einem Server ohne X11: Start, Lizenz aus der Konfiguration, sauberer Stopp per SIGTERM
|
|
||||||
- [ ] Worker-Dauerlauf ≥ 24 h gegen die **Dev-DB**, Logvergleich mit einem Windows-Lauf
|
|
||||||
- [ ] Lauf unter `LANG=de_DE.UTF-8` — Volumina, Preise, PnL gegen die Windows-Referenz prüfen (Kultur-Fallen)
|
|
||||||
- [ ] Web-UI vollständig durchklicken — case-sensitive Assets sind unter Linux ein echtes Risiko
|
|
||||||
- [ ] Lizenz: Erstaktivierung, Neustart aus dem Cache, Offline-Gnadenfrist, Revalidierung
|
|
||||||
- [ ] Watchdog-Heartbeats erscheinen am Deployment Center
|
|
||||||
- [ ] EF-Migrationen von Null gegen die Dev-DB
|
|
||||||
- [ ] Egress-Kanäle, falls genutzt (`SourceIp` verhält sich unter Linux anders)
|
|
||||||
|
|
||||||
### 2.2 🟢 Deployment und CI
|
|
||||||
|
|
||||||
- [ ] `dotnet publish -r linux-x64` und `-r win-x64`; bei self-contained das Trimming-Verhalten von Avalonia beachten
|
|
||||||
- [ ] Linux-Systemabhängigkeiten dokumentieren: `libx11-6`, `libice6`, `libsm6`, `libfontconfig1`, `libharfbuzz0b`, Monospace-Schriften
|
|
||||||
- [ ] systemd-Unit bzw. Dockerfile für den Headless-Betrieb — es existiert bereits `J:\Softwareprojekte\dockerCompose`, vermutlich anknüpfbar
|
|
||||||
- [ ] Reverse Proxy (nginx/Caddy) mit TLS vor Kestrel
|
|
||||||
- [ ] Secrets unter Linux: EnvironmentFile mit `0600` statt Klartext in `settings.json`
|
|
||||||
- [ ] CI: Build + Test auf Linux **und** Windows
|
|
||||||
- [ ] `docs/BETRIEB-Deploymentcenter.md` um den Linux-Betrieb ergänzen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 3 — Analytik schärfen
|
|
||||||
|
|
||||||
### 3.1 🟢 Strategie-Klassifikation reparieren (ehemals A4)
|
|
||||||
|
|
||||||
Der größte fachliche Rückstand. `StrategyType` entsteht aus genau zwei Signalen —
|
|
||||||
`avgSize > 10000` ergibt `Whale`, `hedgingRate > 30` ergibt `Hedger`, sonst `Bot` oder
|
|
||||||
`Unknown`. Deshalb landet der **Großteil der Trader strukturell auf `Unknown`**, was den
|
|
||||||
Wert der Einstufung gegen null gehen lässt.
|
|
||||||
|
|
||||||
Erschwerend: die Logik steht **doppelt** da und die beiden Schreiber überschreiben einander
|
|
||||||
— `AnalyticsService.cs:418` und `ScoringService.cs:89`.
|
|
||||||
|
|
||||||
- [ ] Zuerst die Doppelung auflösen: **ein** Ort, der `StrategyType` bestimmt
|
|
||||||
- [ ] Vorhandene, bereits berechnete Signale einspeisen statt neue zu erheben:
|
|
||||||
`StrategyMetricsCalculator` (Kategorie-Konzentration, Conviction-Edge),
|
|
||||||
`TraderTraitCalculator` (Traits), `FingerprintSnapshotService` (Kategorie-Mix über Zeit)
|
|
||||||
- [ ] Ergänzend, falls nötig: Verteilung der Haltedauern, Anteil später vs. früher Einstiege
|
|
||||||
im Marktleben, Varianz der Ordergrößen
|
|
||||||
- [ ] Prüfen, ob ein Einzel-Enum reicht oder ob Mehrfachsignale danebengehören
|
|
||||||
(Enum als Primär-Tag, Zusatzsignale als eigene Felder)
|
|
||||||
- [ ] Abnahme: der Anteil `Unknown` ist deutlich gesunken
|
|
||||||
|
|
||||||
> Herkunft: [`docs/archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) A4.
|
|
||||||
|
|
||||||
### 3.2 🟡 `CopytradingBacktestHarness` anschließen oder verwerfen
|
|
||||||
|
|
||||||
123 Zeilen fertig implementierter Latenz-Sweep (0/10/30/60 s) samt `BacktestResult`, per DI
|
|
||||||
registriert — und **von niemandem aufgerufen**. Der Sweep würde beziffern, wie viel Alpha
|
|
||||||
ein Nachahmer bei welcher Verzögerung verliert; das passt genau zum Zweck des Projekts.
|
|
||||||
|
|
||||||
Entscheidung nötig: an einen Endpunkt und die Trader-Detailseite hängen, oder ersatzlos
|
|
||||||
löschen. Der jetzige Zustand — gepflegt, aber wirkungslos — ist die schlechteste Variante.
|
|
||||||
|
|
||||||
### 3.3 🟡 Track-Record-Länge in den Copyability-Score
|
|
||||||
|
|
||||||
Der Score besteht heute aus Alpha-Verfall (70 %) und Sizing-Konsistenz (30 %). Die
|
|
||||||
ursprünglich geplante vierte Komponente — Länge und Konsistenz des Track Records — fehlt.
|
|
||||||
Ein Trader mit drei glücklichen Märkten bekommt denselben Score wie einer mit dreihundert.
|
|
||||||
|
|
||||||
> Herkunft: [`archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) A5, letzter Unterpunkt.
|
|
||||||
|
|
||||||
### 3.4 🟡 Edge-Freshness und Strategie-Drift ins Ranking
|
|
||||||
|
|
||||||
Beide Kennzahlen existieren, erscheinen aber nur auf der Detailseite und in Alarmen. In der
|
|
||||||
Rangliste — dort, wo ausgewählt wird — spielen sie keine Rolle. Ein Master mit abgestandener
|
|
||||||
Edge steht weiterhin oben.
|
|
||||||
|
|
||||||
### 3.5 🟡 Co-Movement-Graph vorberechnen
|
|
||||||
|
|
||||||
`GET /api/co-movement/graph` rechnet bei jedem Aufruf. Für die Netzwerkansicht ist das
|
|
||||||
träge und belastet die DB unnötig. In den `ScoringAndAlertsWorker` verlagern und das
|
|
||||||
Ergebnis ablegen.
|
|
||||||
|
|
||||||
### 3.6 🟡 Restpunkte aus der Kategorie-Erkennung
|
|
||||||
|
|
||||||
- [ ] Gezielt fehlende Event-Tags nachladen für Märkte, die getrackte Trader gehandelt haben
|
|
||||||
([`archiv/FIXPLAN-TODO.md`](docs/archiv/FIXPLAN-TODO.md) F5) — der tägliche Sync geschlossener Märkte deckt den Fall
|
|
||||||
inzwischen weitgehend ab, die Lücke ist klein
|
|
||||||
- [ ] Zwei fehlende Tests (F6): Event-Tag-Vererbung und Offline-Backfill
|
|
||||||
|
|
||||||
### 3.7 🟡 Sortierpfeile in der Trader-Tabelle
|
|
||||||
|
|
||||||
Die Tabellenköpfe zeigen statisch `↕`, auch in der aktiven Sortierspalte. Reine Politik,
|
|
||||||
war schon im UI-Plan als „Optional" markiert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 4 — Datenhaushalt
|
|
||||||
|
|
||||||
Alles hier läuft über SQL, das **der Nutzer selbst** gegen die Datenbank fährt. Wir liefern
|
|
||||||
die Anweisungen, führen sie aber nicht aus.
|
|
||||||
|
|
||||||
> Details: [`docs/archiv/FIXPLAN-G-Speicher.md`](docs/archiv/FIXPLAN-G-Speicher.md) (Anhang)
|
|
||||||
> und [`docs/archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md) C3.
|
|
||||||
|
|
||||||
### 4.1 👤 🟡 Einmalige DB-Optimierungen
|
|
||||||
|
|
||||||
- [ ] Spaltentypen verkleinern: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimalstrings in überbreiten Feldern
|
|
||||||
- [ ] InnoDB-Kompression (`ROW_FORMAT=COMPRESSED`) für `Trades` — schneller Zugewinn, geringes Risiko
|
|
||||||
- [ ] Partitionierung von `Trades` nach Monat (`ExecutedAt`) prüfen — erlaubt späteres Verwerfen ganzer Partitionen
|
|
||||||
|
|
||||||
### 4.2 👤 💡 Materialitätsschwelle für Mikro-Trades
|
|
||||||
|
|
||||||
Trades unterhalb eines Betrags (Vorschlag: $0,50) unabhängig vom Retention-Fenster
|
|
||||||
verwerfen. Durchdacht, aber nicht beschlossen — die Schwelle verändert die PnL-Rechnung
|
|
||||||
leicht, und der Gewinn ist ungewiss, bis 4.1 gemessen ist.
|
|
||||||
|
|
||||||
### 4.3 👤 💡 Frischer Datenbank-Neustart
|
|
||||||
|
|
||||||
Die alte Überlegung, die Produktivdatenbank leer neu aufzusetzen und über die Worker neu zu
|
|
||||||
befüllen (Rohdaten sind über `/activity` jederzeit nachladbar). **Nicht beschlossen.** Falls
|
|
||||||
er kommt, gehört er ans Ende von Stufe 3, nicht davor — sonst laufen Analytik-Änderungen und
|
|
||||||
Datenumzug gleichzeitig.
|
|
||||||
|
|
||||||
Vorgelagerter Bestätigungs-Check: stichprobenartig prüfen, wie weit `/activity` für ein
|
|
||||||
lange aktives Wallet tatsächlich zurückreicht.
|
|
||||||
|
|
||||||
### 4.4 💡 65 Migrationsdateien zusammenfassen
|
|
||||||
|
|
||||||
32 Migrationen ergeben 65 Dateien und rund 37.000 Zeilen. Eine neue Baseline wäre
|
|
||||||
übersichtlicher, kostet aber die Schemahistorie und ist gegen eine laufende Produktivdatenbank
|
|
||||||
heikel. Nur sinnvoll im Zuge von 4.3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 5 — Ingest skalieren
|
|
||||||
|
|
||||||
Erst angehen, wenn Rate-Limits tatsächlich drücken. Die wirksamsten Maßnahmen sind bereits
|
|
||||||
umgesetzt (Ingest-Tiering, Egress-Kanäle, Kurzzeit-Cache).
|
|
||||||
|
|
||||||
> Details: [`docs/archiv/PLAN-DatenIngest-Skalierung.md`](docs/archiv/PLAN-DatenIngest-Skalierung.md).
|
|
||||||
|
|
||||||
### 5.1 💡 Historische Massendaten aus der Blockchain statt über REST
|
|
||||||
|
|
||||||
Der größte ungenutzte Hebel. Polymarket-Trades sind On-Chain-Events auf Polygon; für
|
|
||||||
Backfill und Deep-Resync — die teuersten REST-Verbraucher — ist `/activity` die falsche
|
|
||||||
Quelle. Ein GraphQL-Aufruf gegen einen Subgraph (The Graph / Goldsky) liefert tausende Fills
|
|
||||||
eines Wallets auf einmal.
|
|
||||||
|
|
||||||
Skizze: `IHistoricalTradeSource` neben den REST-Provider stellen, Deep-Resync zieht darüber.
|
|
||||||
**Nicht eingeplant**, weil erst zu evaluieren ist, ob ein brauchbarer Subgraph existiert und
|
|
||||||
wie vollständig er ist.
|
|
||||||
|
|
||||||
### 5.2 🟡 Markt-Stammdaten aggressiv cachen
|
|
||||||
|
|
||||||
Metadaten (Frage, Kategorie, Outcomes, `ConditionId`↔`TokenId`) ändern sich nie — einmal
|
|
||||||
ziehen, lang halten; nur Volumen, Liquidität und Auflösung periodisch aktualisieren.
|
|
||||||
Teilweise erledigt: geschlossene Märkte werden nur noch einmal täglich synchronisiert.
|
|
||||||
|
|
||||||
### 5.3 🟡 Egress-Kanäle: Betriebsreste
|
|
||||||
|
|
||||||
Die Mechanik steht (Round-Robin, Cooldown nach drei Fehlschlägen, Limiter je Kanal). Was
|
|
||||||
im Ingest-Plan noch danebensteht:
|
|
||||||
|
|
||||||
- [ ] Health-Statistik je Kanal — Erfolgsquote, 429-Rate, Latenz. Heute gibt es nur einen
|
|
||||||
Fehlerzähler: tote Kanäle werden pausiert, aber nicht ausgewertet
|
|
||||||
- [ ] Bei eigenen IPs prüfen, ob es echte getrennte Egress-IPs sind (multi-homed) und nicht
|
|
||||||
NAT hinter derselben öffentlichen Adresse — sonst bringt die Aufteilung nichts
|
|
||||||
- [ ] Regressionsschutz: die MySQL-Verbindung darf **nie** über den Egress-Pool oder einen
|
|
||||||
Proxy laufen
|
|
||||||
- [ ] Grob unplausible Provider-Antworten abfangen (z. B. Preis außerhalb 0–1)
|
|
||||||
|
|
||||||
### 5.4 ❌ Header- und User-Agent-Rotation
|
|
||||||
|
|
||||||
Im Ingest-Plan bewusst als „standardmäßig AUS" eingeordnet. Rotation zur Verschleierung
|
|
||||||
gegenüber Polymarket ist gegen deren Interesse gerichtet und riskiert die Sperrung genau des
|
|
||||||
Zugangs, auf dem das Projekt aufsetzt. Der ehrliche Weg ist, die Nachfrage zu senken (5.1,
|
|
||||||
5.2) und legitime Kanäle zu erhöhen (bereits umgesetzt).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stufe 6 — Monetarisierung vorbereiten
|
|
||||||
|
|
||||||
Nur relevant, falls aus dem internen Werkzeug ein Produkt werden soll. **Bislang eine Idee,
|
|
||||||
keine Entscheidung.** Die Vorarbeit (Read/Control-Trennung, `/api/capabilities`,
|
|
||||||
CORS-Whitelist) ist im Zuge der Portierung ohnehin schon passiert.
|
|
||||||
|
|
||||||
> Details: [`docs/archiv/PLAN-Architektur-WebUI-Backend.md`](docs/archiv/PLAN-Architektur-WebUI-Backend.md).
|
|
||||||
|
|
||||||
### 6.1 💡 Datenbank für eine öffentliche Schicht vorbereiten
|
|
||||||
|
|
||||||
- **Read-Endpunkte auf versteckte Writes und Provider-Aufrufe prüfen.** Der Read/Control-Split
|
|
||||||
steht, aber ob jeder einzelne Read-Endpunkt frei von Schreibzugriffen ist, wurde nie
|
|
||||||
systematisch geprüft — besonders der Deep-Dive-Pfad. Vor einem öffentlichen Host
|
|
||||||
nachzuholen, sonst wird die read-only Verbindung im Betrieb Fehler werfen
|
|
||||||
- Read-only DB-Benutzer auf der Analyse-DB (nur `SELECT`) — 👤 der Nutzer führt die Grants aus
|
|
||||||
- Schema-Kopplung dokumentieren: liest eine öffentliche Schicht direkt mit, wird das
|
|
||||||
DB-Schema zur **Vertragsfläche**. Views als Puffer erwägen, damit interne Umbauten die
|
|
||||||
öffentliche Sicht nicht brechen.
|
|
||||||
|
|
||||||
Billig und jederzeit vorziehbar, auch wenn 6.2 nie kommt.
|
|
||||||
|
|
||||||
### 6.2 💡 Öffentlicher Host
|
|
||||||
|
|
||||||
Eigenes Projekt `Predictalytics.PublicApi`: registriert nur die Read-Endpunkte, dazu
|
|
||||||
Konten/Authentifizierung in einer **getrennten** User-DB, Rate-Limits und Kontingente pro
|
|
||||||
Konto, Mandantensicht (Kunde sieht seine Watchlist, nicht das ganze Universum), liefert
|
|
||||||
dasselbe SPA aus.
|
|
||||||
|
|
||||||
**Nicht-Ziele, die dabei gelten** — ❌ kein Zugriff der öffentlichen Schicht auf Core-Prozess,
|
|
||||||
lokale API oder Steuerendpunkte; ❌ kein Schreibzugriff auf die Analyse-DB; ❌ keine
|
|
||||||
Kunden-Authentifizierung nachträglich in die lokale Vollschicht bauen; ❌ Read-Endpunkte
|
|
||||||
machen keine Provider-Aufrufe und keine Writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Anhang — bewusst nicht auf der Roadmap
|
|
||||||
|
|
||||||
Geprüft und mit Absicht ausgelassen. Nicht versehentlich wieder aufnehmen:
|
|
||||||
|
|
||||||
| Was | Warum |
|
|
||||||
|---|---|
|
|
||||||
| `AzuroProvider` löschen | Platzhalter mit `IsImplemented => false`, wird sauber übersprungen. Kostet nichts, markiert den Ausbaupfad. |
|
|
||||||
| `Providers/Limitless/` löschen | Voll implementierter zweiter Marktplatz, `IsImplemented => true`. Wird nur nicht bespielt — Löschen wäre Funktionsverlust. |
|
|
||||||
| `wwwroot/landing.html`, `docs.html` | Marketing-Seiten mit Preis-Abschnitt, vom Dashboard nicht verlinkt. Gehören zu Stufe 6. |
|
|
||||||
| Kaltarchiv außerhalb der DB | Verworfen: Rohdaten sind über die Polymarket-API jederzeit nachladbar, ein Archiv wäre reine Zusatzlast. |
|
|
||||||
| EF Core auf 10 heben | Blockiert: Pomelo hat keinen EF-10-Provider und pinnt Relational hart auf `[9.0.0, 9.0.999]`. Vor einem Versuch prüfen, ob Pomelo nachgezogen hat. |
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Predictalytics — Stand des Projekts
|
|
||||||
|
|
||||||
> Bestandsaufnahme vom **2026-08-23**, erstellt beim Frühjahrsputz. Jede Aussage hier ist
|
|
||||||
> gegen den Code geprüft, nicht aus den Plandokumenten übernommen — die waren zu diesem
|
|
||||||
> Zeitpunkt durchweg veraltet.
|
|
||||||
>
|
|
||||||
> Dieses Dokument beschreibt den **Ist-Stand**. Was ansteht, steht in
|
|
||||||
> [`ROADMAP.md`](ROADMAP.md); die abgelösten Plandokumente liegen unter
|
|
||||||
> [`docs/archiv/`](docs/archiv/).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Wo wir stehen
|
|
||||||
|
|
||||||
Predictalytics analysiert Polymarket-Trader auf ihre Eignung fürs Copytrading. Die
|
|
||||||
Anwendung ist **funktional vollständig und läuft im Betrieb**: 151.094 Trader und
|
|
||||||
38,2 Mio. Trades in der Produktivdatenbank (Stand 2026-08-08).
|
|
||||||
|
|
||||||
Der Hauptzweig ist `main`; die Portierung auf .NET 10 und Avalonia ist dort seit dem
|
|
||||||
2026-08-24 angekommen (`402c372`). `feature/linux-net10` steht auf demselben Stand.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Zielframework | net10.0, ein einziges für alle Projekte |
|
|
||||||
| Oberfläche | Avalonia 12.1.1 (Windows + Linux), zusätzlich ein Headless-Modus |
|
|
||||||
| Datenzugriff | EF Core **9** (bewusst; Pomelo hat keinen EF-10-Provider), 32 Migrationen |
|
|
||||||
| Tests | **126 grün, 1 bewusst übersprungen** |
|
|
||||||
| Build | fehlerfrei und **warnungsfrei** |
|
|
||||||
|
|
||||||
### Aufbau
|
|
||||||
|
|
||||||
```
|
|
||||||
Predictalytics.Domain 1.321 Z. Entities, Enums, Repository-Interfaces
|
|
||||||
Predictalytics.Application 3.036 Z. Analytik, Scoring, Kalkulatoren
|
|
||||||
Predictalytics.Infrastructure 4.966 Z. EF-Kontext, Repositories, Provider (+65 Migrationsdateien)
|
|
||||||
Predictalytics.Worker 2.449 Z. 13 Hintergrunddienste
|
|
||||||
Predictalytics.Api 896 Z. Minimal-API-Endpunkte + WebUI (3.247 Z. HTML/JS/CSS)
|
|
||||||
Predictalytics.Hosting 1.972 Z. Plattformneutraler Kern: Kestrel, Worker, Lizenz, Heartbeat
|
|
||||||
Predictalytics.Shell 1.305 Z. Avalonia-Bedienhülle
|
|
||||||
Predictalytics.Application.Tests 3.216 Z. 126 Tests
|
|
||||||
```
|
|
||||||
|
|
||||||
`Predictalytics.Hosting` hängt an einem **Schwester-Repo**: `Deploymentcenter` muss neben
|
|
||||||
dem Predictalytics-Checkout liegen, sonst bricht der Build mit einer erklärenden Meldung ab.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Was fertig ist
|
|
||||||
|
|
||||||
Alles Folgende ist im Code belegt; die Belegstellen stehen in den archivierten Plandokumenten
|
|
||||||
unter [`docs/archiv/`](docs/archiv/).
|
|
||||||
|
|
||||||
* **Kernanalytik** ([`archiv/UMSETZUNGSPLAN.md`](docs/archiv/UMSETZUNGSPLAN.md), Phase 1): positionsbasierte PnL-Engine nach
|
|
||||||
Average-Cost mit sauberer Behandlung jeder `TradeSide`, marktbasierte Win-Rate,
|
|
||||||
Deep-Dive-Kennzahlen aus echter Preishistorie, Copytrading-Score aus gemessenem
|
|
||||||
Alpha-Verfall gegen das Markt-Tape.
|
|
||||||
* **Robustheit** (Phase 2): EF-Migrationen statt handgeschriebener `ALTER TABLE`,
|
|
||||||
entkoppelte Scoring-Pipeline, Log-Retention, Testprojekt. Alle fünf Bugs aus dem
|
|
||||||
Review-Pass vom 2026-07-01 sind behoben.
|
|
||||||
* **Speicher-Haushalt** ([`archiv/FIXPLAN-G-Speicher.md`](docs/archiv/FIXPLAN-G-Speicher.md)): budgetabhängiges Retention-Fenster,
|
|
||||||
Burst-Kompaktierung, Per-Kategorie-Edge.
|
|
||||||
* **Kategorie-Erkennung** ([`archiv/FIXPLAN-TODO.md`](docs/archiv/FIXPLAN-TODO.md), Teil F): kanonische Tag-Tabelle,
|
|
||||||
Rausch-Filter, Tag-Nachladen für On-Demand-Märkte, Offline-Backfill beim Start.
|
|
||||||
* **HF-Trader-Tiering** (Teil D): `IngestMode` mit Hysterese, wöchentliche Biopsie,
|
|
||||||
Aggregations-Buckets.
|
|
||||||
* **Web-UI** ([`archiv/FIXPLAN-UI-Ranglisten.md`](docs/archiv/FIXPLAN-UI-Ranglisten.md)): Showcase-Sektionen, serverseitige Sortierung
|
|
||||||
und Filterung.
|
|
||||||
* **Copytrading-Analytik**: Co-Movement samt Clusterung, Strategie-Drift, Edge-Freshness,
|
|
||||||
Copy-Portfolio, Insider-Feed — alle fünf Vorschläge der Roadmap sind umgesetzt und
|
|
||||||
in diesem Zweig enthalten.
|
|
||||||
* **Portierung** ([`archiv/PLAN-Linux-Portierung.md`](docs/archiv/PLAN-Linux-Portierung.md), Phasen 0–4): .NET 10, extrahierter
|
|
||||||
Hosting-Kern, Avalonia-Shell, abgesicherte Steuerendpunkte, plattformfeste Kultur- und
|
|
||||||
Log-Pfade. Der WinForms-Host ist entfernt.
|
|
||||||
* **Betriebsanbindung**: Lizenz, Watchdog, UpdateService, Fehler-Stream und Bugtracker
|
|
||||||
laufen alle über das Deployment Center.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Was offen ist
|
|
||||||
|
|
||||||
Die offenen Punkte stehen vollständig und nach Reihenfolge sortiert in
|
|
||||||
**[`ROADMAP.md`](ROADMAP.md)** — hier nur die drei, die den Blick aufs Ganze bestimmen:
|
|
||||||
|
|
||||||
1. **Die Portierung ist auf keinem Linux-System je gelaufen.** Der Code ist plattformneutral
|
|
||||||
gebaut, die Ende-zu-Ende-Prüfung vom 2026-08-08 lief aber unter Windows. Solange das
|
|
||||||
aussteht, ist „läuft unter Linux" eine Annahme. Roadmap Stufe 2 — der größte offene Block.
|
|
||||||
2. **Die Strategie-Klassifikation ist faktisch wirkungslos** — zwei Signale, der Großteil
|
|
||||||
der Trader landet auf `Unknown`, und die Logik steht doppelt da. Roadmap 3.1.
|
|
||||||
3. **Die KI-Analyse ist ohne Schlüssel abgeschaltet.** Der alte OpenRouter-Schlüssel ist
|
|
||||||
entfernt (er war inaktiv); ein neuer gehört in die Umgebungsvariable `OpenRouter__ApiKey`,
|
|
||||||
nicht in `appsettings.json`. Bis dahin überspringt die Anwendung die Analyse mit einer
|
|
||||||
Warnung, alles andere läuft normal. Roadmap 0.1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Zweige
|
|
||||||
|
|
||||||
Aufgeräumt am 2026-08-24. Es gibt nur noch zwei, beide auf `402c372`:
|
|
||||||
|
|
||||||
* **`main`** — der Hauptzweig, aktuell.
|
|
||||||
* **`feature/linux-net10`** — identisch mit `main`, bleibt vorerst stehen.
|
|
||||||
|
|
||||||
Die zehn Feature-Zweige der Analytik- und Portierungsarbeit sind lokal und auf origin
|
|
||||||
gelöscht. `fix-event-description-truncation` war der einzige, dessen Commit nie gemergt
|
|
||||||
wurde (nur sein Inhalt, seit `cb58e09`); er ist als Tag
|
|
||||||
`archiv/fix-event-description-truncation` festgehalten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Bewusst stehengelassen
|
|
||||||
|
|
||||||
Beim Aufräumen geprüft und **nicht** entfernt, weil Löschen hier Funktionsverlust wäre und
|
|
||||||
nicht Entrümpelung. Dieselbe Liste steht im Anhang der [`ROADMAP.md`](ROADMAP.md), damit sie
|
|
||||||
nicht versehentlich als Aufgabe wieder auftaucht:
|
|
||||||
|
|
||||||
| Was | Warum es bleibt |
|
|
||||||
|---|---|
|
|
||||||
| `Providers/Azuro/AzuroProvider.cs` | Platzhalter mit `IsImplemented => false`. Die Worker überspringen ihn sauber; er kostet nichts und markiert den Ausbaupfad. |
|
|
||||||
| `Providers/Limitless/` (3 Dateien) | **Voll implementierter** zweiter Marktplatz mit `IsImplemented => true`. Wird produktiv nur nicht bespielt. |
|
|
||||||
| `Services/CopytradingBacktestHarness.cs` (123 Z.) | Fertig implementierter Latenz-Sweep, per DI registriert, aber **von niemandem aufgerufen**. Entscheidung steht aus, siehe Roadmap 3.2. |
|
|
||||||
| `wwwroot/landing.html`, `wwwroot/docs.html` | Marketing-Seiten mit Preis-Abschnitt, vom Dashboard aus nicht verlinkt. Passen zur später angedachten Monetarisierung. |
|
|
||||||
| 65 Migrationsdateien | Die Schemahistorie. Zusammenfassen ginge, ist aber riskant — siehe Roadmap 4.4. |
|
|
||||||
|
|
||||||
*(Ein hier zuvor notierter Nebenbefund — der Kategorie-Backfill lade beim Start alle Märkte
|
|
||||||
in den Speicher — war in zwei Punkten falsch: er läuft nicht beim Start, sondern nur in der
|
|
||||||
manuellen Aktion „Recalculate All Traders", und die Abfrage ist seit dem 2026-08-24
|
|
||||||
eingeschränkt. Siehe Roadmap 1.3.)*
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,51 +1,5 @@
|
|||||||
# Predictalytics – Umsetzungsplan (Phase 1 & 2 + Storage-Optimierung)
|
# Predictalytics – Umsetzungsplan (Phase 1 & 2 + Storage-Optimierung)
|
||||||
|
|
||||||
> ## Stand 2026-08-23 — Phase 1 und 2 sind bis auf zwei Punkte umgesetzt
|
|
||||||
>
|
|
||||||
> Die Häkchen waren bis dahin nie nachgezogen worden: 85 Punkte standen auf offen,
|
|
||||||
> obwohl der Code sie längst enthielt. Am 2026-08-23 abschnittsweise gegen den Code
|
|
||||||
> geprüft und gesetzt. **Belege:**
|
|
||||||
>
|
|
||||||
> | | Belegt durch |
|
|
||||||
> |---|---|
|
|
||||||
> | **A1** PnL-Engine | `Infrastructure/Services/PositionPnLEngine.cs` — alle `TradeSide`-Zweige einzeln behandelt (`:157-217`), `Split`/`Merge` neutral, unrealisiert aus `CurrentPrice − AvgCost` (`:266`), Gesamt-PnL `:290`. Entity `Domain/Entities/TraderPosition.cs`. Tests: `PositionPnLEngineTests.cs` |
|
|
||||||
> | **A2** Win-Rate | `CalculateMarketWinRates` (`PositionPnLEngine.cs:381`) — marktbasiert, kein `return 0` mehr |
|
|
||||||
> | **A3** Deep-Dive | `MarketOutcomePriceSnapshot` existiert und wird ausgewertet (`AnalyticsService.cs:88,390`). Die `50` ist jetzt **Rückfallwert bei fehlender Preishistorie** (`:456,457,474`), kein fester Neutralwert mehr |
|
|
||||||
> | **A5** Copytrading-Score | `Infrastructure/Services/CopytradingEstimator.cs` — **abweichend umgesetzt und besser als geplant**: statt geschätztem Liquiditäts-Fit misst der Score den echten Alpha-Verfall gegen das Markt-Tape (Slippage bei 10 s Folgelatenz, 70 %) plus Sizing-Konsistenz (30 %). Frequenz/Konzentration deckt stattdessen `IngestMode` + Trait `not_copyable_hf` ab. **Track-Record-Länge fließt nicht in den Score ein** |
|
|
||||||
> | **B1/B4** | 32 EF-Migrationen; Testprojekt existiert, **126 grün + 1 Skip** |
|
|
||||||
> | **B3** | `ScoringService.RecalculateAllScoresAsync:127` rechnet nur bei `CalculatedAt < LastTradesUpdatedAt` neu, Rank-Update gebatcht; eigener `ScoringAndAlertsWorker` mit 15-Minuten-Takt |
|
|
||||||
> | **B5** | `LoggingSetup.cs:90-133` — `retainedFileCountLimit` und `fileSizeLimitBytes` gesetzt |
|
|
||||||
> | **B6** | Alle fünf Bugs behoben: geschlossene Märkte nur noch **1×/Tag** (`MarketSyncWorker.cs:52`), Watchlist von der Löschung ausgenommen (`TraderRepository.cs:137`), `GetKnownPlatformTradeIdsAsync` filtert auf die Batch-IDs (`TradeRepository.cs:158`), `_syncSemaphore` in `AddOrUpdateAsync` (`MarketRepository.cs:31`), `break` durch einen Filter ersetzt (`TradeHistoryWorker.cs:200`) |
|
|
||||||
> | **C1/C2** | `TradeRetentionWorker` + `Application/Services/StorageGovernor.cs` (budgetabhängiges Fenster), Burst-Kompaktierung ab `TradeRetentionWorker.cs:148` |
|
|
||||||
>
|
|
||||||
> ### Was wirklich offen ist
|
|
||||||
>
|
|
||||||
> 1. **A4 — Strategie-Klassifikation** (Zeilen 47–54). Der einzige größere unerledigte
|
|
||||||
> Block aus Phase 1. `StrategyType` entsteht weiterhin aus genau zwei Signalen
|
|
||||||
> (`AnalyticsService.cs:418` und, **dupliziert**, `ScoringService.cs:89`):
|
|
||||||
> `avgSize > 10000 → Whale`, `hedgingRate > 30 → Hedger`, sonst `Bot`/`Unknown`.
|
|
||||||
> Genau der Zustand, den A4 beheben sollte — der Großteil der Trader landet
|
|
||||||
> strukturell auf `Unknown`. Die Bausteine dafür liegen bereits fertig herum
|
|
||||||
> (`StrategyMetricsCalculator`, `TraderTraitCalculator`, `FingerprintSnapshotService`),
|
|
||||||
> sie speisen die Klassifikation nur nicht. Die Doppelung gehört beim Anfassen
|
|
||||||
> zusammengeführt.
|
|
||||||
> 2. **B2 — Secrets** (Zeilen 84–86). Nicht mehr „kein akuter Risikofall": in
|
|
||||||
> `src/Predictalytics.Api/appsettings.json` steht ein **echter OpenRouter-API-Key im
|
|
||||||
> Klartext**, versioniert seit Commit `7045002`. Siehe Warnung unten.
|
|
||||||
> 3. **C3** (148–153) und **D1/D2** (165–173) sind DB-Arbeiten auf SQL-Ebene bzw. die
|
|
||||||
> Entscheidung über den Datenbank-Neustart — beides liegt beim Nutzer, nicht im Code.
|
|
||||||
> 4. **C1**, Zeile 137: Stichprobentest, wie weit `/activity` zurückreicht. Eine manuelle
|
|
||||||
> Prüfung, die sich im Code nicht belegen lässt — bewusst offen gelassen.
|
|
||||||
>
|
|
||||||
> ### ⚠️ Sicherheit — Handlungsbedarf
|
|
||||||
>
|
|
||||||
> `src/Predictalytics.Api/appsettings.json` enthält den OpenRouter-API-Key im Klartext
|
|
||||||
> und liegt so im Git-Verlauf und auf dem Gitea-Server. **Den Schlüssel bei OpenRouter
|
|
||||||
> zurückziehen und neu ausstellen** — ihn nur aus der Datei zu löschen hilft nicht, die
|
|
||||||
> Historie behält ihn. Danach über die Umgebungsvariable `OpenRouter__ApiKey` setzen;
|
|
||||||
> `WebApplication.CreateBuilder` liest Umgebungsvariablen bereits mit Vorrang vor
|
|
||||||
> `appsettings.json`, dafür ist keine Codeänderung nötig.
|
|
||||||
|
|
||||||
> **Wie wir das nutzen:** Wir arbeiten diese Liste Stück für Stück ab. Erledigte Punkte werden von `[ ]` auf `[x]` gesetzt. Reihenfolge ist absichtlich so gewählt, dass spätere Schritte auf früheren aufbauen — nicht einfach querbeet abhaken, siehe Abschnitt "Empfohlene Reihenfolge" ganz unten für die Kurzfassung.
|
> **Wie wir das nutzen:** Wir arbeiten diese Liste Stück für Stück ab. Erledigte Punkte werden von `[ ]` auf `[x]` gesetzt. Reihenfolge ist absichtlich so gewählt, dass spätere Schritte auf früheren aufbauen — nicht einfach querbeet abhaken, siehe Abschnitt "Empfohlene Reihenfolge" ganz unten für die Kurzfassung.
|
||||||
>
|
>
|
||||||
> Phase 3 (Monetarisierung: Auth, Multi-Tenant, Billing) ist bewusst **nicht** Teil dieses Plans.
|
> Phase 3 (Monetarisierung: Auth, Multi-Tenant, Billing) ist bewusst **nicht** Teil dieses Plans.
|
||||||
@@ -61,33 +15,33 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
|
|||||||
## Phase 1 – Kernanalytik korrigieren
|
## Phase 1 – Kernanalytik korrigieren
|
||||||
|
|
||||||
### A1. Positionsbasierte PnL-Engine
|
### A1. Positionsbasierte PnL-Engine
|
||||||
- [x] Entscheidung dokumentieren: **Average-Cost-Methode** statt FIFO (einfacher, und – wichtig – kompatibel mit späterer Trade-Kompaktierung in Abschnitt C, weil Average-Cost nur Gesamtstückzahl & Gesamtkosten braucht, keine Einzel-Trade-Reihenfolge)
|
- [ ] Entscheidung dokumentieren: **Average-Cost-Methode** statt FIFO (einfacher, und – wichtig – kompatibel mit späterer Trade-Kompaktierung in Abschnitt C, weil Average-Cost nur Gesamtstückzahl & Gesamtkosten braucht, keine Einzel-Trade-Reihenfolge)
|
||||||
- [x] Neue Domain-Struktur `TraderPosition` (TraderId, MarketOutcomeId, SharesHeld, AvgCost, RealizedPnl, zuletzt aktualisiert) — inkrementell fortschreibbar statt bei jeder Berechnung die komplette Trade-Historie neu zu scannen
|
- [ ] Neue Domain-Struktur `TraderPosition` (TraderId, MarketOutcomeId, SharesHeld, AvgCost, RealizedPnl, zuletzt aktualisiert) — inkrementell fortschreibbar statt bei jeder Berechnung die komplette Trade-Historie neu zu scannen
|
||||||
- [x] Buchungslogik je `TradeSide` sauber definieren:
|
- [ ] Buchungslogik je `TradeSide` sauber definieren:
|
||||||
- [x] `Buy`: Shares += Size, AvgCost neu gewichten
|
- [ ] `Buy`: Shares += Size, AvgCost neu gewichten
|
||||||
- [x] `Sell`: RealizedPnl += Size × (Price − AvgCost), Shares −= Size
|
- [ ] `Sell`: RealizedPnl += Size × (Price − AvgCost), Shares −= Size
|
||||||
- [x] `Redeem` (Marktauflösung): RealizedPnl += verbleibende Shares × (1 oder 0 je nach Gewinn-Outcome − AvgCost), Shares = 0
|
- [ ] `Redeem` (Marktauflösung): RealizedPnl += verbleibende Shares × (1 oder 0 je nach Gewinn-Outcome − AvgCost), Shares = 0
|
||||||
- [x] `Split` / `Merge`: als neutrale Positionsumwandlung behandeln (kein PnL-Effekt), nicht wie `Sell`
|
- [ ] `Split` / `Merge`: als neutrale Positionsumwandlung behandeln (kein PnL-Effekt), nicht wie `Sell`
|
||||||
- [x] `AddLiquidity` / `RemoveLiquidity`: getrennt von Trading-PnL betrachten (eigene Kategorie, fließt nicht in "Trading-Skill"-Bewertung ein)
|
- [ ] `AddLiquidity` / `RemoveLiquidity`: getrennt von Trading-PnL betrachten (eigene Kategorie, fließt nicht in "Trading-Skill"-Bewertung ein)
|
||||||
- [x] Unrealisierten PnL für offene Positionen berechnen: `Shares × (MarketOutcome.CurrentPrice − AvgCost)`
|
- [ ] Unrealisierten PnL für offene Positionen berechnen: `Shares × (MarketOutcome.CurrentPrice − AvgCost)`
|
||||||
- [x] Gesamt-PnL = realisiert + unrealisiert (ersetzt `OverallPnL`, `PnL30d/7d/24h` Felder in `TraderAnalytics`)
|
- [ ] Gesamt-PnL = realisiert + unrealisiert (ersetzt `OverallPnL`, `PnL30d/7d/24h` Felder in `TraderAnalytics`)
|
||||||
- [x] Prüfen, ob wir zusätzlich/alternativ Polymarkets eigenen `/positions`-Endpoint (`GetTraderPositionsAsync`, liefert `CurrentValue`/`PercentPnl`) als Plausibilitäts-Check oder sogar als primäre Quelle für **offene** Positionen nutzen (aktuell komplett ungenutzt)
|
- [ ] Prüfen, ob wir zusätzlich/alternativ Polymarkets eigenen `/positions`-Endpoint (`GetTraderPositionsAsync`, liefert `CurrentValue`/`PercentPnl`) als Plausibilitäts-Check oder sogar als primäre Quelle für **offene** Positionen nutzen (aktuell komplett ungenutzt)
|
||||||
- [x] `CalculatePnL`-Bug aus Abschnitt 0 im Zuge dessen mit erledigen
|
- [ ] `CalculatePnL`-Bug aus Abschnitt 0 im Zuge dessen mit erledigen
|
||||||
|
|
||||||
### A2. Win-Rate korrekt berechnen
|
### A2. Win-Rate korrekt berechnen
|
||||||
- [x] Win/Loss ist **pro Markt**, nicht pro Trade, definiert: ein Markt zählt als "Win", wenn der realisierte PnL aus diesem Markt (nach Redeem/vollständigem Exit) positiv ist
|
- [ ] Win/Loss ist **pro Markt**, nicht pro Trade, definiert: ein Markt zählt als "Win", wenn der realisierte PnL aus diesem Markt (nach Redeem/vollständigem Exit) positiv ist
|
||||||
- [x] `WinRate = Anzahl gewonnener Märkte / Anzahl abgeschlossener Märkte` (offene Positionen zählen nicht mit)
|
- [ ] `WinRate = Anzahl gewonnener Märkte / Anzahl abgeschlossener Märkte` (offene Positionen zählen nicht mit)
|
||||||
- [x] Placeholder `return 0;` in `TraderAnalyticsWorker.CalculateWinRate` ersetzen
|
- [ ] Placeholder `return 0;` in `TraderAnalyticsWorker.CalculateWinRate` ersetzen
|
||||||
|
|
||||||
### A3. Deep-Dive-Kennzahlen mit echten Werten füllen
|
### A3. Deep-Dive-Kennzahlen mit echten Werten füllen
|
||||||
- [x] **Voraussetzung klären:** Für Entry/Exit-Qualität und Timing-Accuracy brauchen wir eine **Preis-Historie** pro `MarketOutcome`, nicht nur den aktuellen Preis (`CurrentPrice`). Aktuell existiert keine Historisierung.
|
- [ ] **Voraussetzung klären:** Für Entry/Exit-Qualität und Timing-Accuracy brauchen wir eine **Preis-Historie** pro `MarketOutcome`, nicht nur den aktuellen Preis (`CurrentPrice`). Aktuell existiert keine Historisierung.
|
||||||
- [x] Prüfen, ob Polymarkets CLOB-API einen Preishistorie-Endpoint (`/prices-history`) hergibt, den wir zum Backfill nutzen können
|
- [ ] Prüfen, ob Polymarkets CLOB-API einen Preishistorie-Endpoint (`/prices-history`) hergibt, den wir zum Backfill nutzen können
|
||||||
- [x] Falls ja: neue Tabelle `MarketOutcomePriceSnapshot` (MarketOutcomeId, Timestamp, Price) einführen, periodisch befüllt (z.B. durch bestehenden `MarketSyncWorker`/`MarketHistoryWorker` erweitern)
|
- [ ] Falls ja: neue Tabelle `MarketOutcomePriceSnapshot` (MarketOutcomeId, Timestamp, Price) einführen, periodisch befüllt (z.B. durch bestehenden `MarketSyncWorker`/`MarketHistoryWorker` erweitern)
|
||||||
- [x] `AvgHoldDurationHours` echt berechnen: gewichtete Haltedauer zwischen Einstieg (Buy-Zeitpunkte, gewichtet nach Größe) und Ausstieg (Sell/Redeem) pro Position
|
- [ ] `AvgHoldDurationHours` echt berechnen: gewichtete Haltedauer zwischen Einstieg (Buy-Zeitpunkte, gewichtet nach Größe) und Ausstieg (Sell/Redeem) pro Position
|
||||||
- [x] `EntryQuality`: Einstiegspreis im Vergleich zur nachfolgenden Preisentwicklung (z.B. Perzentil des Einstiegspreises innerhalb eines Zeitfensters danach)
|
- [ ] `EntryQuality`: Einstiegspreis im Vergleich zur nachfolgenden Preisentwicklung (z.B. Perzentil des Einstiegspreises innerhalb eines Zeitfensters danach)
|
||||||
- [x] `ExitQuality`: analog für Ausstiegspreis
|
- [ ] `ExitQuality`: analog für Ausstiegspreis
|
||||||
- [x] `TimingAccuracy`: z.B. Anteil der Trades, die kurz vor einer für den Trader günstigen Preisbewegung platziert wurden
|
- [ ] `TimingAccuracy`: z.B. Anteil der Trades, die kurz vor einer für den Trader günstigen Preisbewegung platziert wurden
|
||||||
- [x] Hardcodierte `50`-Neutralwerte in `AnalyticsService.PerformDeepDive` entfernen
|
- [ ] Hardcodierte `50`-Neutralwerte in `AnalyticsService.PerformDeepDive` entfernen
|
||||||
|
|
||||||
### A4. Strategie-Klassifikation verbessern
|
### A4. Strategie-Klassifikation verbessern
|
||||||
- [ ] Zusätzliche Signale einbeziehen statt nur "Ø-Größe" und "Hedging-Rate":
|
- [ ] Zusätzliche Signale einbeziehen statt nur "Ø-Größe" und "Hedging-Rate":
|
||||||
@@ -100,17 +54,17 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
|
|||||||
- [ ] Sicherstellen, dass nicht der Großteil der Trader dauerhaft bei "Unknown" landet (aktuell strukturell der Fall)
|
- [ ] Sicherstellen, dass nicht der Großteil der Trader dauerhaft bei "Unknown" landet (aktuell strukturell der Fall)
|
||||||
|
|
||||||
### A5. Copytrading-Eignungs-Score (neu)
|
### A5. Copytrading-Eignungs-Score (neu)
|
||||||
- [x] Neue Metrik-Dimension definieren, unabhängig vom bestehenden `PriorityScore`:
|
- [ ] Neue Metrik-Dimension definieren, unabhängig vom bestehenden `PriorityScore`:
|
||||||
- [x] **Liquiditäts-Fit**: durchschnittliche Positionsgröße im Verhältnis zur Marktliquidität/zum Volumen zum Handelszeitpunkt (Slippage-Risiko für Nachahmer)
|
- [ ] **Liquiditäts-Fit**: durchschnittliche Positionsgröße im Verhältnis zur Marktliquidität/zum Volumen zum Handelszeitpunkt (Slippage-Risiko für Nachahmer)
|
||||||
- [x] **Reaktionsfenster**: wie viel Zeit bliebe einem Copytrader realistisch zum Nachziehen (Trader mit Sekunden/Millisekunden-Kadenz sind nicht kopierbar)
|
- [ ] **Reaktionsfenster**: wie viel Zeit bliebe einem Copytrader realistisch zum Nachziehen (Trader mit Sekunden/Millisekunden-Kadenz sind nicht kopierbar)
|
||||||
- [x] **Frequenz/Konzentration**: sehr hochfrequente/bot-artige Trader senken den Score automatisch
|
- [ ] **Frequenz/Konzentration**: sehr hochfrequente/bot-artige Trader senken den Score automatisch
|
||||||
- [x] **Track-Record-Länge & Konsistenz**: mehr abgeschlossene Märkte mit konsistent positivem PnL = höheres Vertrauen
|
- [ ] **Track-Record-Länge & Konsistenz**: mehr abgeschlossene Märkte mit konsistent positivem PnL = höheres Vertrauen
|
||||||
- [x] Kombinierten `CopytradingScore` (0–100) berechnen und persistieren (neues Feld auf `TraderScore` oder eigene Entity `TraderCopytradingProfile`)
|
- [ ] Kombinierten `CopytradingScore` (0–100) berechnen und persistieren (neues Feld auf `TraderScore` oder eigene Entity `TraderCopytradingProfile`)
|
||||||
- [x] Im Dashboard/API sichtbar machen (getrennt von der bisherigen "Priorität", da unterschiedliche Fragestellung: *gut* vs. *kopierbar*)
|
- [ ] Im Dashboard/API sichtbar machen (getrennt von der bisherigen "Priorität", da unterschiedliche Fragestellung: *gut* vs. *kopierbar*)
|
||||||
|
|
||||||
### A6. (siehe Abschnitt 0) TradeSide-Bugfix in PnL-Berechnung
|
### A6. (siehe Abschnitt 0) TradeSide-Bugfix in PnL-Berechnung
|
||||||
- [x] Erledigt sich durch A1, hier nur als Häkchen zur Nachverfolgung
|
- [ ] Erledigt sich durch A1, hier nur als Häkchen zur Nachverfolgung
|
||||||
- [x] Kurzer Änderungsvermerk/Commit-Hinweis, damit klar ist, dass dieser Bug bewusst behoben wurde
|
- [ ] Kurzer Änderungsvermerk/Commit-Hinweis, damit klar ist, dass dieser Bug bewusst behoben wurde
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +77,7 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
|
|||||||
- [x] Migration end-to-end gegen eine echte, frische DEV-Datenbank getestet (`lqf7.your-database.de`/`bergisnu_db0`, vom Nutzer bereitgestellt) — `dotnet ef database update` lief fehlerfrei durch, `dotnet ef migrations list` bestätigt sie als angewendet
|
- [x] Migration end-to-end gegen eine echte, frische DEV-Datenbank getestet (`lqf7.your-database.de`/`bergisnu_db0`, vom Nutzer bereitgestellt) — `dotnet ef database update` lief fehlerfrei durch, `dotnet ef migrations list` bestätigt sie als angewendet
|
||||||
- [x] `AppDbContextFactory` (Design-Time-Factory für `dotnet ef`) liest Zielverbindung jetzt aus Env-Vars (`PREDICTALYTICS_DB_SERVER/_NAME/_USER/_PASSWORD`, via `MySqlConnectionStringBuilder` statt roher String-Konkatenation, da Passwörter Sonderzeichen wie `;`/`{}` enthalten können) statt fest codiert — kein Secret mehr im Repo nötig, um Migrationen gegen eine beliebige DB zu fahren
|
- [x] `AppDbContextFactory` (Design-Time-Factory für `dotnet ef`) liest Zielverbindung jetzt aus Env-Vars (`PREDICTALYTICS_DB_SERVER/_NAME/_USER/_PASSWORD`, via `MySqlConnectionStringBuilder` statt roher String-Konkatenation, da Passwörter Sonderzeichen wie `;`/`{}` enthalten können) statt fest codiert — kein Secret mehr im Repo nötig, um Migrationen gegen eine beliebige DB zu fahren
|
||||||
- [x] ~~Live-Produktions-DB manuell als "bereits migriert" markieren~~ — vorerst zurückgestellt: Da wir laut Abschnitt D ohnehin einen kompletten Neustart der Produktions-DB planen (Altdaten sind jederzeit nachladbar, siehe C0), richten wir die neue Produktions-DB am Ende genauso ein wie die DEV-DB (leer anlegen + `dotnet ef database update`) — der fummelige Stamp-Schritt auf die bestehende Live-DB entfällt dann komplett. Nur falls wir uns doch gegen den Neustart entscheiden, müsste dieser Schritt nachgeholt werden
|
- [x] ~~Live-Produktions-DB manuell als "bereits migriert" markieren~~ — vorerst zurückgestellt: Da wir laut Abschnitt D ohnehin einen kompletten Neustart der Produktions-DB planen (Altdaten sind jederzeit nachladbar, siehe C0), richten wir die neue Produktions-DB am Ende genauso ein wie die DEV-DB (leer anlegen + `dotnet ef database update`) — der fummelige Stamp-Schritt auf die bestehende Live-DB entfällt dann komplett. Nur falls wir uns doch gegen den Neustart entscheiden, müsste dieser Schritt nachgeholt werden
|
||||||
- [x] Alle künftigen Schemaänderungen aus Phase 1 (z.B. `TraderPosition`, `MarketOutcomePriceSnapshot`, `TraderCopytradingProfile`) als reguläre Migrationen anlegen — direkt gegen die DEV-DB entwickeln/testen, da wir dort laut Nutzer frei experimentieren dürfen
|
- [ ] Alle künftigen Schemaänderungen aus Phase 1 (z.B. `TraderPosition`, `MarketOutcomePriceSnapshot`, `TraderCopytradingProfile`) als reguläre Migrationen anlegen — direkt gegen die DEV-DB entwickeln/testen, da wir dort laut Nutzer frei experimentieren dürfen
|
||||||
|
|
||||||
### B2. Secrets-Management (Release-Vorbereitung, kein akuter Risikofall)
|
### B2. Secrets-Management (Release-Vorbereitung, kein akuter Risikofall)
|
||||||
> Software läuft aktuell nur lokal, kein Fremdzugriff — Passwort bleibt vorerst wie es ist, keine Rotation nötig. Dieser Punkt ist reine **Vorbereitung**, damit das Projekt bei Bedarf später auch ohne die aktuellen Secrets veröffentlicht/geteilt werden könnte, ohne den Code nochmal anfassen zu müssen. Dadurch niedrigere Priorität als vorher angenommen — kann später in der Reihenfolge stehen.
|
> Software läuft aktuell nur lokal, kein Fremdzugriff — Passwort bleibt vorerst wie es ist, keine Rotation nötig. Dieser Punkt ist reine **Vorbereitung**, damit das Projekt bei Bedarf später auch ohne die aktuellen Secrets veröffentlicht/geteilt werden könnte, ohne den Code nochmal anfassen zu müssen. Dadurch niedrigere Priorität als vorher angenommen — kann später in der Reihenfolge stehen.
|
||||||
@@ -132,27 +86,27 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
|
|||||||
- [ ] Keine Passwort-Rotation nötig, solange rein lokale Nutzung
|
- [ ] Keine Passwort-Rotation nötig, solange rein lokale Nutzung
|
||||||
|
|
||||||
### B3. Scoring-Pipeline entkoppeln
|
### B3. Scoring-Pipeline entkoppeln
|
||||||
- [x] `PollingWorker` soll **nicht** bei jedem 60-Sekunden-Zyklus alle Trader neu bewerten
|
- [ ] `PollingWorker` soll **nicht** bei jedem 60-Sekunden-Zyklus alle Trader neu bewerten
|
||||||
- [x] Nur Trader neu bewerten, die seit letzter Berechnung neue Trades bekommen haben (Dirty-Flag oder Vergleich `LastTradesUpdatedAt` vs. `TraderScore.CalculatedAt`)
|
- [ ] Nur Trader neu bewerten, die seit letzter Berechnung neue Trades bekommen haben (Dirty-Flag oder Vergleich `LastTradesUpdatedAt` vs. `TraderScore.CalculatedAt`)
|
||||||
- [x] N+1-Datenbankzugriffe in `ScoringService.RecalculateAllScoresAsync` durch Batch-Queries ersetzen
|
- [ ] N+1-Datenbankzugriffe in `ScoringService.RecalculateAllScoresAsync` durch Batch-Queries ersetzen
|
||||||
- [x] Eigenes Intervall für volle Neuberechnung/Ranking (z.B. alle 15 Min) getrennt vom reinen Trade-Polling (60s)
|
- [ ] Eigenes Intervall für volle Neuberechnung/Ranking (z.B. alle 15 Min) getrennt vom reinen Trade-Polling (60s)
|
||||||
|
|
||||||
### B6. Weitere gefundene Bugs/Performance-Probleme (Review-Pass 2026-07-01)
|
### B6. Weitere gefundene Bugs/Performance-Probleme (Review-Pass 2026-07-01)
|
||||||
- [x] **`MarketSyncWorker` re-synct alle 30 Minuten ALLE Märkte, inkl. `includeClosed=true`, von Offset 0** ([MarketSyncWorker.cs](../../src/Predictalytics.Worker/Services/MarketSyncWorker.cs)) — holt damit bei jedem Zyklus jeden jemals geschlossenen Polymarket-Markt erneut komplett durch. Wächst unbegrenzt mit der Zeit, echtes Risiko für Rate-Limiting/Sperrung. Sollte auf: aktive Märkte häufig, geschlossene Märkte selten/inkrementell (z.B. nur kürzlich geschlossene, nicht der komplette Bestand) umgestellt werden
|
- [ ] **`MarketSyncWorker` re-synct alle 30 Minuten ALLE Märkte, inkl. `includeClosed=true`, von Offset 0** ([MarketSyncWorker.cs](src/Predictalytics.Worker/Services/MarketSyncWorker.cs)) — holt damit bei jedem Zyklus jeden jemals geschlossenen Polymarket-Markt erneut komplett durch. Wächst unbegrenzt mit der Zeit, echtes Risiko für Rate-Limiting/Sperrung. Sollte auf: aktive Märkte häufig, geschlossene Märkte selten/inkrementell (z.B. nur kürzlich geschlossene, nicht der komplette Bestand) umgestellt werden
|
||||||
- [x] **Watchlisted Trader nicht von Auto-Löschung ausgeschlossen** (`TraderRepository.GetTradersForCleanupAsync` / `TraderCleanupWorker`) — da `Trades`/`TraderScore`/`WatchlistEntries` per Cascade am Trader hängen, könnte ein manuell beobachteter Trader nach 1 Jahr Inaktivität (oder bei kurzzeitigem API-Fehler) unbemerkt komplett gelöscht werden. Watchlist-Einträge sollten von der Cleanup-Query ausgenommen werden
|
- [ ] **Watchlisted Trader nicht von Auto-Löschung ausgeschlossen** (`TraderRepository.GetTradersForCleanupAsync` / `TraderCleanupWorker`) — da `Trades`/`TraderScore`/`WatchlistEntries` per Cascade am Trader hängen, könnte ein manuell beobachteter Trader nach 1 Jahr Inaktivität (oder bei kurzzeitigem API-Fehler) unbemerkt komplett gelöscht werden. Watchlist-Einträge sollten von der Cleanup-Query ausgenommen werden
|
||||||
- [x] **`TradeRepository.GetKnownPlatformTradeIdsAsync` lädt die komplette Trade-ID-Historie eines Traders ins RAM**, nur um eine kleine neu geholte Charge (~100-1000 Trades) zu deduplizieren — aufgerufen alle 60s (`PollingWorker`) bzw. alle 12h (`TradeHistoryWorker`) pro Trader. Wird mit wachsender Trade-Zahl immer teurer. Fix: nur `WHERE PlatformTradeId IN (<geholte Batch-IDs>)` abfragen statt der gesamten Historie
|
- [ ] **`TradeRepository.GetKnownPlatformTradeIdsAsync` lädt die komplette Trade-ID-Historie eines Traders ins RAM**, nur um eine kleine neu geholte Charge (~100-1000 Trades) zu deduplizieren — aufgerufen alle 60s (`PollingWorker`) bzw. alle 12h (`TradeHistoryWorker`) pro Trader. Wird mit wachsender Trade-Zahl immer teurer. Fix: nur `WHERE PlatformTradeId IN (<geholte Batch-IDs>)` abfragen statt der gesamten Historie
|
||||||
- [x] **Race Condition in `MarketRepository.AddOrUpdateAsync`** (im Gegensatz zu `AddOrUpdateRangeAsync` ohne Locking) — `TradeHistoryWorker` verarbeitet bis zu 5 Trader parallel (`Parallel.ForEachAsync`); referenzieren zwei gleichzeitig denselben noch unbekannten Markt, prüfen beide unabhängig "existiert nicht" und einer crasht beim `Add` mit Unique-Constraint-Verletzung (wird geloggt, Trader-Sync für den Zyklus bricht ab, nächster Zyklus heilt es meist). Fix: gleiches Locking-Muster wie `AddOrUpdateRangeAsync` verwenden, oder Insert-Konflikt sauber abfangen/retry
|
- [ ] **Race Condition in `MarketRepository.AddOrUpdateAsync`** (im Gegensatz zu `AddOrUpdateRangeAsync` ohne Locking) — `TradeHistoryWorker` verarbeitet bis zu 5 Trader parallel (`Parallel.ForEachAsync`); referenzieren zwei gleichzeitig denselben noch unbekannten Markt, prüfen beide unabhängig "existiert nicht" und einer crasht beim `Add` mit Unique-Constraint-Verletzung (wird geloggt, Trader-Sync für den Zyklus bricht ab, nächster Zyklus heilt es meist). Fix: gleiches Locking-Muster wie `AddOrUpdateRangeAsync` verwenden, oder Insert-Konflikt sauber abfangen/retry
|
||||||
- [x] **`TradeHistoryWorker` bricht die Trade-Verarbeitung beim ersten bekannten Trade ab** (`if (!isInitial) break;`), verlässt sich also darauf, dass die API immer streng neueste-zuerst liefert. `PollingWorker`s äquivalente Schleife macht das NICHT. Sollte angeglichen werden — der Performance-Gewinn ist gering gegenüber dem Risiko einer stillen Datenlücke, falls die Annahme mal nicht stimmt
|
- [ ] **`TradeHistoryWorker` bricht die Trade-Verarbeitung beim ersten bekannten Trade ab** (`if (!isInitial) break;`), verlässt sich also darauf, dass die API immer streng neueste-zuerst liefert. `PollingWorker`s äquivalente Schleife macht das NICHT. Sollte angeglichen werden — der Performance-Gewinn ist gering gegenüber dem Risiko einer stillen Datenlücke, falls die Annahme mal nicht stimmt
|
||||||
|
|
||||||
### B4. Testabdeckung für die kritische Logik
|
### B4. Testabdeckung für die kritische Logik
|
||||||
- [x] Neues Testprojekt (z.B. `Predictalytics.Application.Tests`) anlegen — aktuell existiert **kein einziges** Testprojekt
|
- [ ] Neues Testprojekt (z.B. `Predictalytics.Application.Tests`) anlegen — aktuell existiert **kein einziges** Testprojekt
|
||||||
- [x] Unit-Tests für die neue PnL-Engine (A1) — insbesondere Grenzfälle: nur offene Position, nur geschlossene Position, Split/Merge, Redeem-Verlust vs. -Gewinn
|
- [ ] Unit-Tests für die neue PnL-Engine (A1) — insbesondere Grenzfälle: nur offene Position, nur geschlossene Position, Split/Merge, Redeem-Verlust vs. -Gewinn
|
||||||
- [x] Unit-Tests für Win-Rate (A2)
|
- [ ] Unit-Tests für Win-Rate (A2)
|
||||||
- [x] Unit-Tests für Deep-Dive-Kennzahlen (A3) und Copytrading-Score (A5)
|
- [ ] Unit-Tests für Deep-Dive-Kennzahlen (A3) und Copytrading-Score (A5)
|
||||||
- [x] Diese Tests idealerweise **parallel zu A1–A5** schreiben, nicht erst am Ende nachziehen
|
- [ ] Diese Tests idealerweise **parallel zu A1–A5** schreiben, nicht erst am Ende nachziehen
|
||||||
|
|
||||||
### B5. Kleine verwandte Aufräumarbeit
|
### B5. Kleine verwandte Aufräumarbeit
|
||||||
- [x] Log-Rotation/Retention der Serilog-Datei-Sinks (`WinFormsHost/logs`) prüfen — wächst potenziell unbegrenzt, ähnliches Prinzip wie die DB-Speicherplatzfrage unten
|
- [ ] Log-Rotation/Retention der Serilog-Datei-Sinks (`WinFormsHost/logs`) prüfen — wächst potenziell unbegrenzt, ähnliches Prinzip wie die DB-Speicherplatzfrage unten
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,20 +129,20 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
|
|||||||
→ Vorschlag: **Burst-Erkennung statt starrer Zeit-Buckets** (nur tatsächlich dichte Trade-Sequenzen kompaktieren) und **Aggregation getrennt nach Trader+Markt/Outcome+Seite** mit **VWAP** (mengengewichteter Durchschnittspreis) statt einfachem Durchschnitt — bleibt unter der Average-Cost-Methode aus A1 nahezu verlustfrei für die PnL-Berechnung.
|
→ Vorschlag: **Burst-Erkennung statt starrer Zeit-Buckets** (nur tatsächlich dichte Trade-Sequenzen kompaktieren) und **Aggregation getrennt nach Trader+Markt/Outcome+Seite** mit **VWAP** (mengengewichteter Durchschnittspreis) statt einfachem Durchschnitt — bleibt unter der Average-Cost-Methode aus A1 nahezu verlustfrei für die PnL-Berechnung.
|
||||||
|
|
||||||
### C1. Rollierendes Zeitfenster statt Archivierung (ersetzt die alte "Idee 1")
|
### C1. Rollierendes Zeitfenster statt Archivierung (ersetzt die alte "Idee 1")
|
||||||
- [x] Konfigurierbares Retention-Fenster einführen (Default-Vorschlag: 3–6 Monate) — gilt **für alle Trader gleichermaßen**, nicht nur für inaktive. Länge sollte sich daran orientieren, wie weit die Deep-Dive-/Strategie-Analyse (A3/A4) tatsächlich zurückschaut, um "aktuelle Strategie" zu charakterisieren
|
- [ ] Konfigurierbares Retention-Fenster einführen (Default-Vorschlag: 3–6 Monate) — gilt **für alle Trader gleichermaßen**, nicht nur für inaktive. Länge sollte sich daran orientieren, wie weit die Deep-Dive-/Strategie-Analyse (A3/A4) tatsächlich zurückschaut, um "aktuelle Strategie" zu charakterisieren
|
||||||
- [x] Reihenfolge pro Trade zwingend einhalten: **erst** in `TraderPosition`/Monats-Aggregat (s.u.) einrechnen und das sicher persistieren, **dann erst** die Rohzeile löschen — rein zeitbasiert, unabhängig davon ob die betroffene Position noch offen oder schon geschlossen ist
|
- [ ] Reihenfolge pro Trade zwingend einhalten: **erst** in `TraderPosition`/Monats-Aggregat (s.u.) einrechnen und das sicher persistieren, **dann erst** die Rohzeile löschen — rein zeitbasiert, unabhängig davon ob die betroffene Position noch offen oder schon geschlossen ist
|
||||||
- [x] Kein Export/Cold-Storage nötig, da jederzeit über die Polymarket-API bzw. im Zweifel über die Blockchain nachladbar — vereinfacht C1 gegenüber der ursprünglichen Idee erheblich (keine Baseline-Felder, keine Reaktivierungs-Sonderfälle)
|
- [ ] Kein Export/Cold-Storage nötig, da jederzeit über die Polymarket-API bzw. im Zweifel über die Blockchain nachladbar — vereinfacht C1 gegenüber der ursprünglichen Idee erheblich (keine Baseline-Felder, keine Reaktivierungs-Sonderfälle)
|
||||||
- [x] Optionales, leichtgewichtiges Langzeit-Signal (nice-to-have, niedrige Priorität): ein grobes Monats-Aggregat pro Trader (Monat, realisierter PnL, Trade-Anzahl, Volumen) für einen "war er über Monate hinweg konsistent profitabel"-Trend im Copytrading-Score (A5) — **ohne** Trade-Detailtiefe, nur wenige Kennzahlen pro Monat
|
- [ ] Optionales, leichtgewichtiges Langzeit-Signal (nice-to-have, niedrige Priorität): ein grobes Monats-Aggregat pro Trader (Monat, realisierter PnL, Trade-Anzahl, Volumen) für einen "war er über Monate hinweg konsistent profitabel"-Trend im Copytrading-Score (A5) — **ohne** Trade-Detailtiefe, nur wenige Kennzahlen pro Monat
|
||||||
- [x] Neuer periodischer Cleanup-Job (`TradeRetentionWorker`), der Trades außerhalb des Fensters findet und löscht, nachdem die Voraussetzung (Position/Aggregat aktuell) erfüllt ist
|
- [ ] Neuer periodischer Cleanup-Job (`TradeRetentionWorker`), der Trades außerhalb des Fensters findet und löscht, nachdem die Voraussetzung (Position/Aggregat aktuell) erfüllt ist
|
||||||
- [ ] Kurzer Stichprobentest, wie weit `/activity` pro Wallet tatsächlich zurückreicht (bestätigt/verifiziert nur die schon vorliegende Doku-Aussage, geringe Priorität da schon durch Nutzer-Recherche plausibilisiert)
|
- [ ] Kurzer Stichprobentest, wie weit `/activity` pro Wallet tatsächlich zurückreicht (bestätigt/verifiziert nur die schon vorliegende Doku-Aussage, geringe Priorität da schon durch Nutzer-Recherche plausibilisiert)
|
||||||
|
|
||||||
### C2. Kompaktierung von Hochfrequenz-Tradern innerhalb des Zeitfensters (überarbeitete "Idee 2")
|
### C2. Kompaktierung von Hochfrequenz-Tradern innerhalb des Zeitfensters (überarbeitete "Idee 2")
|
||||||
- [x] Burst-Erkennung statt globalem Zeitraster: Sequenz von Trades mit Abstand kleiner als Schwellwert (z.B. 60s, konfigurierbar) zwischen aufeinanderfolgenden Trades **desselben Traders, Outcomes und derselben Seite (Buy/Sell)** gilt als "Burst"
|
- [ ] Burst-Erkennung statt globalem Zeitraster: Sequenz von Trades mit Abstand kleiner als Schwellwert (z.B. 60s, konfigurierbar) zwischen aufeinanderfolgenden Trades **desselben Traders, Outcomes und derselben Seite (Buy/Sell)** gilt als "Burst"
|
||||||
- [x] Mindestlänge für Kompaktierung festlegen (z.B. erst ab 20+ Trades im Burst lohnt sich das)
|
- [ ] Mindestlänge für Kompaktierung festlegen (z.B. erst ab 20+ Trades im Burst lohnt sich das)
|
||||||
- [x] Bestehende Bot-Heuristik (`intervals.Average() < 10` in `AnalyticsService`) als Ausgangspunkt wiederverwenden/verallgemeinern statt eine zweite, unabhängige Definition einzuführen
|
- [ ] Bestehende Bot-Heuristik (`intervals.Average() < 10` in `AnalyticsService`) als Ausgangspunkt wiederverwenden/verallgemeinern statt eine zweite, unabhängige Definition einzuführen
|
||||||
- [x] Aggregat-Datensatz pro Burst: Anzahl Trades, Summe Size, Summe Amount, Min-Preis, Max-Preis, **VWAP** (nicht einfacher Durchschnitt)
|
- [ ] Aggregat-Datensatz pro Burst: Anzahl Trades, Summe Size, Summe Amount, Min-Preis, Max-Preis, **VWAP** (nicht einfacher Durchschnitt)
|
||||||
- [x] Design-Entscheidung: Aggregat als zusätzliche nullable Spalten auf der bestehenden `Trade`-Tabelle (`AggregateCount`, `AggregateMinPrice`, `AggregateMaxPrice`) statt separater Tabelle — bestehender Code (PnL, Deep-Dive) muss dadurch kaum angepasst werden, ein Aggregat-Datensatz ist einfach ein "Trade" mit `Size = Summe`, `Price = VWAP`
|
- [ ] Design-Entscheidung: Aggregat als zusätzliche nullable Spalten auf der bestehenden `Trade`-Tabelle (`AggregateCount`, `AggregateMinPrice`, `AggregateMaxPrice`) statt separater Tabelle — bestehender Code (PnL, Deep-Dive) muss dadurch kaum angepasst werden, ein Aggregat-Datensatz ist einfach ein "Trade" mit `Size = Summe`, `Price = VWAP`
|
||||||
- [x] **Reihenfolge beachten:** Diese Kompaktierung erst implementieren, nachdem A1 (neue PnL-Engine) steht und validiert ist — sonst kompaktieren wir Daten weg, bevor wir wissen, was die neue Engine wirklich braucht
|
- [ ] **Reihenfolge beachten:** Diese Kompaktierung erst implementieren, nachdem A1 (neue PnL-Engine) steht und validiert ist — sonst kompaktieren wir Daten weg, bevor wir wissen, was die neue Engine wirklich braucht
|
||||||
|
|
||||||
### C3. Weitere eigene Vorschläge
|
### C3. Weitere eigene Vorschläge
|
||||||
- [ ] **Spaltentypen verkleinern**: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimal-Strings fester Länge (z.B. `0x` + 64 Hex-Zeichen) — als `BINARY(32)` statt `VARCHAR(66/80)` speichern spart ca. 30–50% Platz auf diesen stark indizierten Spalten und ist schneller
|
- [ ] **Spaltentypen verkleinern**: `TransactionHash`, `MarketId`, `AssetId` sind Hex-/Dezimal-Strings fester Länge (z.B. `0x` + 64 Hex-Zeichen) — als `BINARY(32)` statt `VARCHAR(66/80)` speichern spart ca. 30–50% Platz auf diesen stark indizierten Spalten und ist schneller
|
||||||
@@ -199,7 +153,7 @@ Beim Review ist aufgefallen, dass `TraderAnalyticsWorker.CalculatePnL` **jeden T
|
|||||||
- [ ] `Split`/`Merge`/`AddLiquidity`/`RemoveLiquidity`-Ereignisse (siehe A1) ggf. separat und kompakter ablegen, da sie für die Trader-Bewertung meist weniger relevant sind als `Buy`/`Sell`
|
- [ ] `Split`/`Merge`/`AddLiquidity`/`RemoveLiquidity`-Ereignisse (siehe A1) ggf. separat und kompakter ablegen, da sie für die Trader-Bewertung meist weniger relevant sind als `Buy`/`Sell`
|
||||||
|
|
||||||
### C4. Abhängigkeit zu Phase 1
|
### C4. Abhängigkeit zu Phase 1
|
||||||
- [x] Merksatz: **Erst A1 (neue PnL-Engine) fertigstellen, dann C1/C2 umsetzen.** Sonst laufen wir Gefahr, Rohdaten wegzuoptimieren, die die neue Engine noch gebraucht hätte.
|
- [ ] Merksatz: **Erst A1 (neue PnL-Engine) fertigstellen, dann C1/C2 umsetzen.** Sonst laufen wir Gefahr, Rohdaten wegzuoptimieren, die die neue Engine noch gebraucht hätte.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
+7
-9
@@ -1,12 +1,10 @@
|
|||||||
# Predictalytics REST API — Referenz
|
# Predictalytics REST API — Referenz
|
||||||
|
|
||||||
> Stand: 2026-08-23 · Interne API, nur lokal betreiben.
|
> Stand: 2026-07-11 · Interne API ohne Authentifizierung (nur lokal betreiben!).
|
||||||
> **Lesende** Endpunkte sind ungeschützt, **steuernde** verlangen seit Phase 4 den
|
> Interaktive Doku: **`/swagger`** auf dem laufenden Server (Standalone-API und
|
||||||
> Header `X-Predictalytics-Key`, sobald in den Einstellungen ein Token hinterlegt ist
|
> WinForms-Embedded-Server registrieren identische Endpoints über
|
||||||
> (siehe `ApiTokenFilter`; `GET /api/capabilities` meldet der WebUI den Ist-Zustand).
|
> `ApiConfiguration.MapPredictalyticsEndpoints()` — dort ist die einzige Stelle,
|
||||||
> Interaktive Doku: **`/swagger`** auf dem laufenden Server. Endpunkte werden
|
> an der neue Endpoints registriert werden dürfen).
|
||||||
> ausschliesslich in `ApiConfiguration.MapPredictalyticsReadEndpoints()` bzw.
|
|
||||||
> `MapPredictalyticsControlEndpoints()` registriert — nur dort neue ergänzen.
|
|
||||||
|
|
||||||
Konventionen: JSON mit camelCase-Feldnamen · Zeiten in UTC (ISO 8601) ·
|
Konventionen: JSON mit camelCase-Feldnamen · Zeiten in UTC (ISO 8601) ·
|
||||||
Prozente auf 0–100-Skala · Beträge in USD · `WinRate` ist marktbasiert
|
Prozente auf 0–100-Skala · Beträge in USD · `WinRate` ist marktbasiert
|
||||||
@@ -142,8 +140,8 @@ Jobs werden von den Workern abgearbeitet (Worker müssen laufen!). Status:
|
|||||||
| `GET /api/dev/verify-positions/{traderId}` | Vergleicht unsere `TraderPositions` mit Polymarkets `/positions`-API; Antwort: `mismatches[]`, `match`, `dbPositionsCount`, `apiPositionsCount` |
|
| `GET /api/dev/verify-positions/{traderId}` | Vergleicht unsere `TraderPositions` mit Polymarkets `/positions`-API; Antwort: `mismatches[]`, `match`, `dbPositionsCount`, `apiPositionsCount` |
|
||||||
|
|
||||||
> Ein früherer `POST /api/dev/repair-db` wurde entfernt (fehlerhaftes SQL,
|
> Ein früherer `POST /api/dev/repair-db` wurde entfernt (fehlerhaftes SQL,
|
||||||
> destruktiv). Reparatur läuft über die Schaltfläche **Recalculate All Traders**
|
> destruktiv). Reparatur läuft über den WinForms-Menüpunkt **Development →
|
||||||
> in der Avalonia-Shell.
|
> Recalculate All Traders**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
# Betrieb: Deployment Center
|
|
||||||
|
|
||||||
Das **Deployment Center** (`https://dc.mhdf.de`, Schwester-Repo
|
|
||||||
`J:\Softwareprojekte\Deploymentcenter`) hat die beiden früheren Einzelserver abgelöst:
|
|
||||||
|
|
||||||
| Vorher | Jetzt |
|
|
||||||
|---|---|
|
|
||||||
| WatchDog auf `watchdog.mhdf.de`, `POST /api/heartbeat`, Header `X-Watchdog-Key` | Deployment Center, `POST /api/watchdog/v1/ping`, `Authorization: Bearer` |
|
|
||||||
| LicenseLabrador auf `license.mhdf.de`, Ed25519-signierte Envelopes | Deployment Center, `POST /api/license/v1/validate`, Hardware-ID v2 |
|
|
||||||
| — | UpdateService, Fehler-Stream und Bugtracker (neu) |
|
|
||||||
|
|
||||||
Die Anbindung sitzt in `Predictalytics.Hosting` und referenziert das
|
|
||||||
C#-SDK `Deploymentcenter.Client` aus dem Schwester-Repo:
|
|
||||||
|
|
||||||
```
|
|
||||||
..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Repo muss also neben dem Predictalytics-Checkout liegen. `LicenseLabrador`
|
|
||||||
und `WatchDog` werden nicht mehr gebraucht.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Was einmalig zu tun ist
|
|
||||||
|
|
||||||
1. Im Deployment Center unter **Token-Verwaltung → Master-Token erstellen** ein
|
|
||||||
Token mit den Rechten **`watchdog:ping`** und **`bugtracker:report`** anlegen.
|
|
||||||
2. Im Host unter **Settings → Deployment Center → API Token** eintragen.
|
|
||||||
Der alte `X-Watchdog-Key` funktioniert nicht mehr — es ist ein anderer Server.
|
|
||||||
3. Beim ersten Start erscheint einmalig der Lizenzdialog: der Lizenzschlüssel muss
|
|
||||||
erneut eingegeben werden, weil die Aktivierung jetzt am Deployment Center hängt.
|
|
||||||
4. Serverseitig muss der **Evaluator-Cron** laufen — ohne ihn bleibt ein
|
|
||||||
abgestürzter Dienst dauerhaft grün:
|
|
||||||
```
|
|
||||||
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Token landet in der `settings.json` neben der Exe (nicht im Git).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Konfiguration (PropertyGrid, Kategorie „Deployment Center")
|
|
||||||
|
|
||||||
| Feld | Default | Bedeutung |
|
|
||||||
|---|---|---|
|
|
||||||
| `Server URL` | `https://dc.mhdf.de` | **schreibgeschützt**, siehe unten |
|
|
||||||
| `API Token` | *(leer)* | `Authorization: Bearer`. Ohne Token: keine Heartbeats, keine Fehlermeldungen. |
|
|
||||||
| `Heartbeat aktiv` | `true` | Dead-Man's-Switch an/aus |
|
|
||||||
| `Monitor Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Ping) |
|
|
||||||
| `Monitor Instance` | `default` | falls mehrere Instanzen laufen |
|
|
||||||
| `Heartbeat-Intervall (Sekunden)` | `60` | `warning` nach 2×, `down` nach 4× |
|
|
||||||
| `Fehler melden` | `true` | Error/Fatal an den Fehler-Stream |
|
|
||||||
| `Update-Prüfung beim Start` | `true` | prüft nur, installiert nichts von selbst |
|
|
||||||
| `Update-Kanal` | `prod` | `prod`, `beta` oder `dev` |
|
|
||||||
|
|
||||||
**Warum ist die Server-URL nicht einstellbar?** Sie entscheidet, wohin die
|
|
||||||
Lizenzprüfung geht und woher Update-Pakete kommen. Ein einstellbarer Endpoint
|
|
||||||
würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu
|
|
||||||
zeigen. Sie steht als Konstante in `Services/DcConfig.cs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Watchdog (Dead-Man's-Switch)
|
|
||||||
|
|
||||||
`Services/DcHeartbeatService.cs` sendet alle *n* Sekunden einen
|
|
||||||
`POST /api/watchdog/v1/ping`. Beim regulären Schließen geht ein Ping mit
|
|
||||||
`status: "stopped"` raus — der Evaluator lässt einen so beendeten Monitor in
|
|
||||||
Ruhe, bis wieder ein normaler Heartbeat eintrifft. Ohne das folgt wenige Minuten
|
|
||||||
nach jedem geordneten Beenden ein Fehlalarm.
|
|
||||||
|
|
||||||
**Ein Ausfall des Deployment Centers darf Predictalytics nie beeinträchtigen.**
|
|
||||||
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
|
|
||||||
Folgefehler nur noch auf Debug-Level (keine Log-Flut). Wird das Token abgelehnt
|
|
||||||
(`401`/`403`), stellt der Dienst die Versuche ganz ein — Wiederholen kann daran
|
|
||||||
nichts ändern.
|
|
||||||
|
|
||||||
### Mitgesendeter Gesundheitszustand
|
|
||||||
|
|
||||||
Ein Heartbeat beweist nur, dass ein Timer läuft. Deshalb schickt die App ihren
|
|
||||||
selbst ermittelten Zustand mit:
|
|
||||||
|
|
||||||
| `checks` | Bedeutung |
|
|
||||||
|---|---|
|
|
||||||
| `db` | `SELECT 1` gegen die konfigurierte MySQL, gedeckelt auf 5 s. Schlägt sie fehl, stuft der Server den Heartbeat auf `warning`. |
|
|
||||||
|
|
||||||
| `metrics` | Bedeutung |
|
|
||||||
|---|---|
|
|
||||||
| `uptime_sec` | Laufzeit des Prozesses |
|
|
||||||
| `workers_running` | 0/1 — Hintergrund-Worker gestartet |
|
|
||||||
| `webserver_running` | 0/1 — eingebetteter Kestrel gestartet |
|
|
||||||
| `memory_mb` | verwalteter Heap |
|
|
||||||
| `db_size_mb` | zuletzt ermittelte Datenbankgröße |
|
|
||||||
|
|
||||||
Zusätzlich geht seit Server 2.1 das Feld `version` mit — im Dashboard ist damit
|
|
||||||
sichtbar, welcher Build läuft, und ein Ausfall lässt sich einem Rollout zuordnen.
|
|
||||||
|
|
||||||
Gestoppte Worker sind **bewusst kein** fehlgeschlagener Check: das ist ein
|
|
||||||
gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf `warning`
|
|
||||||
halten. Der Zustand steckt als Metrik drin und lässt sich dort auswerten.
|
|
||||||
|
|
||||||
Metriken werden serverseitig 14 Tage lang mit Verlauf gehalten
|
|
||||||
(`GET /api/watchdog/v1/metrics?source=Predictalytics`).
|
|
||||||
|
|
||||||
### Maschine mit überwachen
|
|
||||||
|
|
||||||
Der Heartbeat deckt nur *diesen* Prozess ab. Für die Maschine selbst gehört
|
|
||||||
zusätzlich ein OS-Agent auf den Host (siehe
|
|
||||||
`Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`). Läuft beides, sollte im
|
|
||||||
WebUI unter **WatchDog → System-Hierarchie** die *Übergeordnete Entität* des
|
|
||||||
Predictalytics-Monitors auf den Host gesetzt werden — dann erzeugt ein
|
|
||||||
Maschinenausfall eine Meldung statt zwei.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Lizenzierung
|
|
||||||
|
|
||||||
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt;
|
|
||||||
`Program.Main` bricht sonst ab, bevor die MainForm entsteht. Ohne gültige Lizenz
|
|
||||||
erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
|
|
||||||
|
|
||||||
- **Produkt-Slug:** `predictalytics`
|
|
||||||
- **Hardware-ID v2:** `2:win:<sha256>` aus `HKLM\...\Cryptography\MachineGuid`.
|
|
||||||
Der Rechnername steckt **nicht** im Hash — Umbenennen kostet keinen
|
|
||||||
Aktivierungsplatz.
|
|
||||||
- **Offline-Cache:** AES-256-GCM + DPAPI unter `%AppData%\predictalytics\license\state.dat`
|
|
||||||
(Schema 3 seit SDK 2.1, Schema 2 wird noch gelesen). Dort liegt auch der
|
|
||||||
Lizenzschlüssel — `LicenseClient.TryGetCachedKey()` holt ihn beim Start, damit
|
|
||||||
ohne Dialog revalidiert werden kann.
|
|
||||||
- **Revalidierung zur Laufzeit:** alle 12 h.
|
|
||||||
- **Gemeldete Version:** `LicenseClient.DefaultAppVersion` wird in `Program.Main`
|
|
||||||
auf `BuildInfo.Version` gesetzt; ohne das trüge jede Installation in der
|
|
||||||
Aktivierungsliste dieselbe „1.0.0".
|
|
||||||
|
|
||||||
### Offline-Gnadenfrist
|
|
||||||
|
|
||||||
Seit Server 2.1 ist sie **echt begrenzt**: der Server liefert `cache_ttl_hours`
|
|
||||||
(je Projekt im WebUI, Standard 168 h = 7 Tage). Vorher galt faktisch das
|
|
||||||
Lizenz-Ablaufdatum, bei einer Lizenz bis 2040 also unbegrenzt.
|
|
||||||
|
|
||||||
Läuft die Frist in weniger als 48 h ab, warnt der Host beim Start und bei jeder
|
|
||||||
Revalidierung. Die verbleibende Frist zeigt **Deployment Center → Lizenzstatus
|
|
||||||
anzeigen**. Falls Predictalytics planmäßig länger offline laufen soll, muss die
|
|
||||||
TTL im WebUI für das Projekt `predictalytics` heraufgesetzt werden.
|
|
||||||
|
|
||||||
### Wann die App sich beendet — und wann nicht
|
|
||||||
|
|
||||||
Maßgeblich ist `LicenseValidationResult.IsTransient`:
|
|
||||||
|
|
||||||
| | Zustände | Reaktion |
|
|
||||||
|---|---|---|
|
|
||||||
| `IsValid` | `valid`, `valid_offline` | weiter |
|
|
||||||
| `IsTransient` | `server_unavailable`, `cache_expired` | **kein Urteil, nur eine gescheiterte Verbindung** — weiterlaufen, beim nächsten Durchlauf erneut versuchen |
|
|
||||||
| sonst | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Meldung und Beenden |
|
|
||||||
|
|
||||||
`cache_expired` beendet die laufende Sitzung also nicht — der **nächste Start**
|
|
||||||
bleibt aber am Lizenzdialog hängen, weil `IsValid` dann falsch ist. Der Host
|
|
||||||
loggt diesen Fall deshalb als Error, nicht bloß als Warnung.
|
|
||||||
|
|
||||||
### Deaktivierung bei PC-Wechsel
|
|
||||||
|
|
||||||
`POST /api/license/v1/deactivate` verlangt den `shared_key` aus der
|
|
||||||
Server-Konfiguration. Der gehört **nicht** in die ausgelieferte Anwendung,
|
|
||||||
deshalb ist die Funktion hier nicht verdrahtet. Übliche Route: im WebUI unter
|
|
||||||
**Lizenzen → Hardware-Liste → „Freigeben"**. Die Hardware-ID zeigt der Host unter
|
|
||||||
**Deployment Center → Lizenzstatus anzeigen**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Fehler-Stream
|
|
||||||
|
|
||||||
`Services/DcErrorReporter.cs` meldet Laufzeitfehler an
|
|
||||||
`POST /api/errors/v1/report`. Zwei Wege führen dorthin:
|
|
||||||
|
|
||||||
1. **Globale Handler** — `Application.ThreadException` (UI-Thread),
|
|
||||||
`AppDomain.UnhandledException` (Level `fatal`, wird blockierend gesendet, weil
|
|
||||||
der Prozess gleich weg ist) und `TaskScheduler.UnobservedTaskException`.
|
|
||||||
2. **Serilog-Sink** (`Services/DcErrorSink.cs`) — jedes `Log.Error`/`Log.Fatal`,
|
|
||||||
auch aus Worker und Kestrel.
|
|
||||||
|
|
||||||
Lokale Bremse, damit das Rate-Limit (60/min und IP) nicht verbrannt wird:
|
|
||||||
höchstens 20 Meldungen pro Minute, derselbe Fehler höchstens alle 5 Minuten.
|
|
||||||
Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt;
|
|
||||||
betriebsbedingtes Rauschen gehört in die **Ignore-Regeln** des WebUI — ein
|
|
||||||
Treffer zählt weiter, meldet aber nicht.
|
|
||||||
|
|
||||||
> Der Serilog-Filter in `Program.cs` wirft `Duplicate entry`-Ausnahmen schon
|
|
||||||
> vorher weg; die erreichen den Stream also gar nicht.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Updates
|
|
||||||
|
|
||||||
`Services/DcUpdateService.cs` fragt beim Start
|
|
||||||
`GET /api/updateservice/v1/check` ab (ohne Token). Gefunden wird nur — installiert
|
|
||||||
wird nichts von selbst:
|
|
||||||
|
|
||||||
- Kein Update: Info ins Log.
|
|
||||||
- Update verfügbar: Warnung ins Log, Hinweis in der Statusleiste.
|
|
||||||
- **Kritisches** Update: zusätzlich ein Dialog.
|
|
||||||
- Menü **Deployment Center → Nach Updates suchen** prüft jederzeit von Hand.
|
|
||||||
|
|
||||||
Liegt `update-agent.exe` neben der Anwendung, bietet der Dialog an, ihn zu
|
|
||||||
starten; die Anwendung meldet dann vorher `stopped` und beendet sich. Fehlt der
|
|
||||||
Agent, weist der Dialog auf die manuelle Installation hin.
|
|
||||||
|
|
||||||
Seit SDK 2.1 liefert auch der API-Zweig vollständige Release-Daten
|
|
||||||
(Download-Adresse, Prüfsumme, Changelog, Kritikalität) — der Dialog zeigt die
|
|
||||||
Release Notes also auch dann, wenn die statische `latest.json` fehlt.
|
|
||||||
|
|
||||||
### Versionsstand
|
|
||||||
|
|
||||||
`Predictalytics.Hosting.csproj` importiert `Deploymentcenter.BuildInfo.targets`
|
|
||||||
und erzeugt daraus zur Übersetzungszeit `Predictalytics.Hosting.BuildInfo`
|
|
||||||
mit `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`, `Channel` und
|
|
||||||
`Summary`. Quelle ist `<Version>` in `Directory.Build.props` — beim Release dort
|
|
||||||
hochziehen und mit
|
|
||||||
|
|
||||||
```
|
|
||||||
pack-and-deploy --project predictalytics --version <x> --channel prod
|
|
||||||
```
|
|
||||||
|
|
||||||
veröffentlichen. Dieselbe Version geht an die Lizenz-Aktivierungsliste, an den
|
|
||||||
Heartbeat und als `build` an den Fehler-Stream; `GitCommitShort` reist im
|
|
||||||
`context` jeder Fehlermeldung mit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Bugtracker
|
|
||||||
|
|
||||||
Der Agenten-Workflow ist in [`../CLAUDE.md`](../CLAUDE.md) beschrieben.
|
|
||||||
Projekt-Slug: `predictalytics`. Items, deren `resolved_in_build` einer
|
|
||||||
veröffentlichten Version entspricht, schließen sich beim Publish von selbst.
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Betrieb: Watchdog-Überwachung & LicenseLabrador-Lizenzierung
|
||||||
|
|
||||||
|
Beide Integrationen sitzen im `Predictalytics.WinFormsHost` (dem Produktiv-Host) und
|
||||||
|
binden zwei eigenständige Schwester-Projekte an:
|
||||||
|
|
||||||
|
| Projekt | Pfad | Rolle |
|
||||||
|
|---|---|---|
|
||||||
|
| Watchdog | `J:\Softwareprojekte\WatchDog` | PHP/MySQL-Server auf `watchdog.mhdf.de`, empfängt Heartbeats |
|
||||||
|
| LicenseLabrador | `J:\Softwareprojekte\LicenseLabrador` | PHP-Lizenzserver auf `license.mhdf.de` + C#-SDK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Watchdog (Dead-Man's-Switch)
|
||||||
|
|
||||||
|
`Services/WatchdogHeartbeatService.cs` sendet alle *n* Sekunden einen
|
||||||
|
`POST /api/heartbeat` an den Watchdog. Bleiben die Heartbeats aus — weil die App
|
||||||
|
abgestürzt ist oder die ganze Maschine weg ist — schlägt der Watchdog Alarm.
|
||||||
|
Beim regulären Schließen geht ein `POST /api/event` mit `kind=stopped_graceful`
|
||||||
|
raus, damit ein geplantes Beenden nicht als Crash alarmiert wird.
|
||||||
|
|
||||||
|
> **Nicht auf `kind=stopping` ändern.** Der Watchdog akzeptiert den Wert im
|
||||||
|
> Router, aber `event_log.kind` ist ein ENUM ohne `stopping`. Der Server setzt
|
||||||
|
> dann zwar noch den Zustand auf `stopped`, scheitert aber am Insert und liefert
|
||||||
|
> HTTP 500 — das Event fehlt in der Historie. Verifiziert am 2026-07-30.
|
||||||
|
|
||||||
|
**Wichtig:** Ein Ausfall des Watchdogs darf Predictalytics nie beeinträchtigen.
|
||||||
|
Alle Aufrufe sind best effort; der erste Fehlschlag wird als Warnung geloggt,
|
||||||
|
Folgefehler nur noch auf Debug-Level (keine Log-Flut).
|
||||||
|
|
||||||
|
### Konfiguration (PropertyGrid im Host, Kategorie „Watchdog")
|
||||||
|
|
||||||
|
| Feld | Default | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `Enabled` | `true` | Heartbeats an/aus |
|
||||||
|
| `Server URL` | `https://watchdog.mhdf.de` | Basis-URL |
|
||||||
|
| `API Key` | *(leer)* | `X-Watchdog-Key` — Shared Key **oder** Agent-Token. Ohne Key passiert nichts. |
|
||||||
|
| `Source` | `Predictalytics` | Monitor-Name im Dashboard (Auto-Registrierung beim ersten Beat) |
|
||||||
|
| `Instance` | `default` | falls mehrere Instanzen laufen |
|
||||||
|
| `Interval (Sekunden)` | `60` | Sende-Takt; Alarm nach ca. `Intervall × 1,5 + 30 s` |
|
||||||
|
|
||||||
|
Der Key landet in der `settings.json` neben der Exe (nicht im Git).
|
||||||
|
|
||||||
|
### PolyTrader-Maschine mit überwachen
|
||||||
|
|
||||||
|
Der Heartbeat aus Predictalytics deckt nur *diesen* Prozess ab. Damit auch die
|
||||||
|
Maschine überwacht wird, auf der PolyTrader läuft, gehört dort zusätzlich der
|
||||||
|
OS-Agent hin: `WatchDog\agents\windows\watchdog-agent.ps1` als Scheduled Task
|
||||||
|
(inkl. Shutdown-Hook), bzw. `agents/linux/watchdog-agent.sh` per systemd-Timer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. LicenseLabrador (Kopierschutz)
|
||||||
|
|
||||||
|
`Services/LicenseGuard.cs` prüft beim Start, ob eine nutzbare Lizenz vorliegt
|
||||||
|
(`Program.Main` bricht sonst ab, bevor die MainForm überhaupt entsteht).
|
||||||
|
Ohne gültige Lizenz erscheint `Services/LicenseDialog.cs` zur Key-Eingabe.
|
||||||
|
|
||||||
|
Produkt-Slug, Endpoint und der Ed25519-Public-Key sind **bewusst einkompiliert**
|
||||||
|
und nicht konfigurierbar — ein einstellbarer Endpoint würde erlauben, die App auf
|
||||||
|
einen gefälschten Lizenzserver zu zeigen.
|
||||||
|
|
||||||
|
- **Produkt-Slug:** `predictalytics`
|
||||||
|
- **Offline-Gnadenfrist:** 168 h (7 Tage) — danach ist Serverkontakt nötig
|
||||||
|
- **Revalidierung zur Laufzeit:** alle 12 h; bei Widerruf/Ablauf beendet sich die App
|
||||||
|
- **Härtung:** `VerifyChecksum` (HMAC über State + Key + Hardware-ID) gegen Memory-Patches;
|
||||||
|
Nonce-Reflexion und Signaturprüfung übernimmt das SDK
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Produkt `predictalytics` und Lizenzschlüssel sind angelegt; die Aktivierung wurde
|
||||||
|
am 2026-07-30 gegen den Produktivserver verifiziert (`Valid`, gültig bis
|
||||||
|
2040-12-31, Checksum-Prüfung bestanden). Der einkompilierte Public Key passt zum
|
||||||
|
`signing.pub` des Servers — sonst käme `TamperSuspected` statt `Valid`.
|
||||||
|
|
||||||
|
Die Aktivierung ist an die Hardware-ID der jeweiligen Maschine gebunden. Auf
|
||||||
|
einem neuen Rechner erscheint einmalig der Dialog; der Cache liegt danach unter
|
||||||
|
`%AppData%\predictalytics\license`.
|
||||||
|
|
||||||
|
### Deaktivierung bei PC-Wechsel
|
||||||
|
|
||||||
|
`LicenseClient.DeactivateAsync()` gibt die Aktivierung wieder frei. Aktuell nicht
|
||||||
|
in der UI verdrahtet — bei Bedarf als Menüpunkt ergänzen.
|
||||||
+8
-30
@@ -1,27 +1,5 @@
|
|||||||
# Plan: Drei-Schichten-Architektur (Core / Lokal / Public)
|
# Plan: Drei-Schichten-Architektur (Core / Lokal / Public)
|
||||||
|
|
||||||
> **Archiviert am 2026-08-23** — offene Punkte siehe [`ROADMAP.md`](../../ROADMAP.md),
|
|
||||||
> Stufe 6. Dieses Dokument wird nicht mehr gepflegt.
|
|
||||||
>
|
|
||||||
> **Phase 0 bis 2 sind erledigt**, überwiegend als Nebenprodukt der Linux-Portierung, und
|
|
||||||
> hier nachträglich abgehakt:
|
|
||||||
> * Read/Control-Split (`ApiConfiguration.MapPredictalyticsReadEndpoints()` bzw.
|
|
||||||
> `MapPredictalyticsControlEndpoints()`), `/api/dev/*` und die Job-Trigger liegen auf der
|
|
||||||
> Control-Seite.
|
|
||||||
> * `GET /api/capabilities` existiert und meldet den tatsächlichen Zustand; die WebUI
|
|
||||||
> koppelt ihre Admin-Aktionen daran (`app.js:880,1132`).
|
|
||||||
> * `API_BASE` ist im SPA konfigurierbar (`app.js:2`), Trait-Chips sind da.
|
|
||||||
> * CORS läuft über eine Origin-Whitelist, die Bind-Adresse ist explizit
|
|
||||||
> (`PredictalyticsHost.cs:177,198-201`) — kein `0.0.0.0`.
|
|
||||||
>
|
|
||||||
> **Nicht abgehakt** ist der Deep-Dive-Audit auf versteckte Writes in Read-Endpunkten
|
|
||||||
> (Phase 0, dritter Punkt): die Trennung steht, ob jeder Read-Endpunkt einzeln daraufhin
|
|
||||||
> geprüft wurde, lässt sich im Nachhinein nicht belegen. Vor einem öffentlichen Host
|
|
||||||
> nachholen.
|
|
||||||
>
|
|
||||||
> **Phase 3 und 4 sind offen** und bilden Roadmap-Stufe 6 — dort als Konzept markiert,
|
|
||||||
> weil über eine Monetarisierung nicht entschieden ist.
|
|
||||||
|
|
||||||
> Stand: 2026-07-12 · Status: **Architektur bestätigt** (Nutzer-Entscheidung), Implementierung
|
> Stand: 2026-07-12 · Status: **Architektur bestätigt** (Nutzer-Entscheidung), Implementierung
|
||||||
> schrittweise. Kein Code in diesem Schritt.
|
> schrittweise. Kein Code in diesem Schritt.
|
||||||
> Kontext: Neues WebUI-Design (mit Claude Design vorbereitet) wird nach der Schicht-Trennung
|
> Kontext: Neues WebUI-Design (mit Claude Design vorbereitet) wird nach der Schicht-Trennung
|
||||||
@@ -110,24 +88,24 @@ und von beiden Hosts wiederverwendet — kein Duplizieren:
|
|||||||
## 3. Implementierungsplan (schrittweise)
|
## 3. Implementierungsplan (schrittweise)
|
||||||
|
|
||||||
### Phase 0 — Endpunkte klassifizieren & Read/Control trennen (Fundament, klein)
|
### Phase 0 — Endpunkte klassifizieren & Read/Control trennen (Fundament, klein)
|
||||||
- [x] Jeden Endpunkt in `docs/API.md` als **Read** (public-tauglich) oder **Control** (nur lokal)
|
- [ ] Jeden Endpunkt in `docs/API.md` als **Read** (public-tauglich) oder **Control** (nur lokal)
|
||||||
markieren. Kandidaten Control: alle `POST/PUT/DELETE`, `/api/dev/*`, Job-Trigger.
|
markieren. Kandidaten Control: alle `POST/PUT/DELETE`, `/api/dev/*`, Job-Trigger.
|
||||||
- [ ] Auf **versteckte Writes/Provider-Calls** in Read-Endpunkten prüfen (Deep-Dive!) und
|
- [ ] Auf **versteckte Writes/Provider-Calls** in Read-Endpunkten prüfen (Deep-Dive!) und
|
||||||
bereinigen oder als Control einstufen.
|
bereinigen oder als Control einstufen.
|
||||||
- [x] `MapPredictalyticsEndpoints()` in `MapReadEndpoints()` + `MapControlEndpoints()` splitten
|
- [ ] `MapPredictalyticsEndpoints()` in `MapReadEndpoints()` + `MapControlEndpoints()` splitten
|
||||||
(rein struktureller Refactor, Verhalten unverändert; bestehende Tests bleiben grün).
|
(rein struktureller Refactor, Verhalten unverändert; bestehende Tests bleiben grün).
|
||||||
- [x] `GET /api/capabilities` einführen.
|
- [ ] `GET /api/capabilities` einführen.
|
||||||
|
|
||||||
### Phase 1 — WebUI als sauberes, gemeinsames SPA (parallel zum neuen Design)
|
### Phase 1 — WebUI als sauberes, gemeinsames SPA (parallel zum neuen Design)
|
||||||
- [x] Neues Design als **ein statisches Artefakt** bauen, reiner API-Client, `API_BASE`
|
- [ ] Neues Design als **ein statisches Artefakt** bauen, reiner API-Client, `API_BASE`
|
||||||
konfigurierbar (heute `''` = gleiche Origin bleibt lokal gültig).
|
konfigurierbar (heute `''` = gleiche Origin bleibt lokal gültig).
|
||||||
- [x] Admin-Aktionen an `capabilities.canControl` koppeln.
|
- [ ] Admin-Aktionen an `capabilities.canControl` koppeln.
|
||||||
- [x] Trait-Chips/-Filter im Design vorsehen — **erscheinen erst, wenn Backend-D2 steht**
|
- [ ] Trait-Chips/-Filter im Design vorsehen — **erscheinen erst, wenn Backend-D2 steht**
|
||||||
(kein UI-Bug, das Backend liefert Traits noch nicht).
|
(kein UI-Bug, das Backend liefert Traits noch nicht).
|
||||||
|
|
||||||
### Phase 2 — Lokale Grenze härten (klein, jetzt schon sinnvoll)
|
### Phase 2 — Lokale Grenze härten (klein, jetzt schon sinnvoll)
|
||||||
- [x] CORS von `AllowAnyOrigin` auf konfigurierte Origin-Whitelist umstellen.
|
- [ ] CORS von `AllowAnyOrigin` auf konfigurierte Origin-Whitelist umstellen.
|
||||||
- [x] Bind-Adresse explizit (localhost/LAN), nie `0.0.0.0` ohne Firewall; Konfig-Kommentar
|
- [ ] Bind-Adresse explizit (localhost/LAN), nie `0.0.0.0` ohne Firewall; Konfig-Kommentar
|
||||||
„NIEMALS ins Internet — das ist die interne Schicht".
|
„NIEMALS ins Internet — das ist die interne Schicht".
|
||||||
|
|
||||||
### Phase 3 — DB für Public vorbereiten (mittel, kann früh passieren)
|
### Phase 3 — DB für Public vorbereiten (mittel, kann früh passieren)
|
||||||
@@ -1,26 +1,5 @@
|
|||||||
# Plan: Daten-Ingest skalieren (Rate-Limits, Egress-Kanäle, Blockchain)
|
# Plan: Daten-Ingest skalieren (Rate-Limits, Egress-Kanäle, Blockchain)
|
||||||
|
|
||||||
> **Archiviert am 2026-08-23** — offene Punkte siehe [`ROADMAP.md`](../../ROADMAP.md),
|
|
||||||
> Stufe 5. Dieses Dokument wird nicht mehr gepflegt.
|
|
||||||
>
|
|
||||||
> **Erledigt und hier nachträglich abgehakt:**
|
|
||||||
> * **1c Ingest-Tiering** — `IngestMode` mit `SnapshotOnly`/`Aggregated`, siehe
|
|
||||||
> [`FIXPLAN-TODO.md`](FIXPLAN-TODO.md) Teil D.
|
|
||||||
> * **1d Redundanz** — `IMemoryCache` im `PolymarketApiClient`.
|
|
||||||
> * **2a/2b Egress-Kanäle** — `Infrastructure/Services/EgressPoolService.cs` und
|
|
||||||
> `EgressPoolHandler.cs`: Round-Robin über die aktiven Kanäle, Umschalten rein per
|
|
||||||
> Konfiguration, Cooldown nach drei Fehlschlägen in Folge (`:152`), sodass ein 429 nur
|
|
||||||
> den betroffenen Kanal trifft. Der Limiter-Schlüssel enthält den Kanal
|
|
||||||
> (`RateLimiterService.cs:36`). Tests: `EgressPoolTests.cs`.
|
|
||||||
>
|
|
||||||
> **Teilweise:** Health-Statistik je Kanal (Erfolgsquote, 429-Rate, Latenz) fehlt — es gibt
|
|
||||||
> nur Fehlerzähler und Cooldown. Tote Kanäle werden also pausiert, aber nicht ausgewertet.
|
|
||||||
>
|
|
||||||
> **Offen:** 1a (Blockchain/Subgraph als Bulk-Quelle) und 1b (Markt-Stammdaten-Cache) sind
|
|
||||||
> Roadmap 5.1 und 5.2. **Abschnitt 3a (Header-Rotation) ist bewusst verworfen** — siehe
|
|
||||||
> Roadmap 5.3: Verschleierung gegenüber Polymarket riskiert genau den Zugang, auf dem das
|
|
||||||
> Projekt aufsetzt.
|
|
||||||
|
|
||||||
> Stand: 2026-07-12 · Status: **Richtung bestätigt** (Nutzer: beide Egress-Wege umschaltbar
|
> Stand: 2026-07-12 · Status: **Richtung bestätigt** (Nutzer: beide Egress-Wege umschaltbar
|
||||||
> umsetzen, sofern Aufwand vertretbar). Kein Code in diesem Schritt.
|
> umsetzen, sofern Aufwand vertretbar). Kein Code in diesem Schritt.
|
||||||
> Problem: Polymarket-Rate-Limits bremsen den Datenimport teils extrem aus.
|
> Problem: Polymarket-Rate-Limits bremsen den Datenimport teils extrem aus.
|
||||||
@@ -53,11 +32,11 @@ REST-Verbraucher) ist `/activity` die falsche Quelle.
|
|||||||
nur Volume/Liquidity/Auflösung periodisch aktualisieren. Geschlossene Märkte nie voll re-syncen.
|
nur Volume/Liquidity/Auflösung periodisch aktualisieren. Geschlossene Märkte nie voll re-syncen.
|
||||||
|
|
||||||
### 1c. Ingest-Tiering (FIXPLAN D3)
|
### 1c. Ingest-Tiering (FIXPLAN D3)
|
||||||
- [x] Ultra-HF-Trader im `SnapshotOnly`-Modus erzeugen null Trade-Calls (PnL aus `/positions` +
|
- [ ] Ultra-HF-Trader im `SnapshotOnly`-Modus erzeugen null Trade-Calls (PnL aus `/positions` +
|
||||||
Leaderboard, wöchentliche Biopsie). Entfernt genau die Wallets, die das Budget auffressen.
|
Leaderboard, wöchentliche Biopsie). Entfernt genau die Wallets, die das Budget auffressen.
|
||||||
|
|
||||||
### 1d. Redundanz vermeiden
|
### 1d. Redundanz vermeiden
|
||||||
- [x] Worker-übergreifender Kurzzeit-Cache „gerade geholt", damit nicht mehrere Worker denselben
|
- [ ] Worker-übergreifender Kurzzeit-Cache „gerade geholt", damit nicht mehrere Worker denselben
|
||||||
Markt/Trader kurz hintereinander abrufen.
|
Markt/Trader kurz hintereinander abrufen.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,18 +59,18 @@ Ein Kanal ist genau eine Ausgangsroute, per Config als einer von zwei Typen defi
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- [x] Pro Kanal **ein** `SocketsHttpHandler`:
|
- [ ] Pro Kanal **ein** `SocketsHttpHandler`:
|
||||||
- `SourceIp` → `ConnectCallback`, Socket vor Connect an die lokale IP binden
|
- `SourceIp` → `ConnectCallback`, Socket vor Connect an die lokale IP binden
|
||||||
(`socket.Bind(new IPEndPoint(ip, 0))`).
|
(`socket.Bind(new IPEndPoint(ip, 0))`).
|
||||||
- `Proxy` → `handler.Proxy = new WebProxy(url); handler.UseProxy = true;`.
|
- `Proxy` → `handler.Proxy = new WebProxy(url); handler.UseProxy = true;`.
|
||||||
- [x] `IEgressPool` verteilt Requests round-robin/least-loaded über die aktiven Kanäle.
|
- [ ] `IEgressPool` verteilt Requests round-robin/least-loaded über die aktiven Kanäle.
|
||||||
Leere/❑ Kanalliste = heutiges Verhalten (ein Default-Ausgang).
|
Leere/❑ Kanalliste = heutiges Verhalten (ein Default-Ausgang).
|
||||||
- [x] Umschalten „nur eigene IPs" ↔ „nur Proxys" ↔ „Mix" = Config ändern, kein Deploy-Umbau.
|
- [ ] Umschalten „nur eigene IPs" ↔ „nur Proxys" ↔ „Mix" = Config ändern, kein Deploy-Umbau.
|
||||||
|
|
||||||
### 2b. Rate-Limiter **pro Kanal** (Generalisierung des globalen Limiters)
|
### 2b. Rate-Limiter **pro Kanal** (Generalisierung des globalen Limiters)
|
||||||
- [x] Limiter-Schlüssel wird `{platform}-{endpointGroup}-{channelId}`. Jeder Kanal hält sein
|
- [ ] Limiter-Schlüssel wird `{platform}-{endpointGroup}-{channelId}`. Jeder Kanal hält sein
|
||||||
eigenes Budget → N Kanäle ≈ N× Durchsatz, jeder Kanal bleibt unter dem Per-Route-Limit.
|
eigenes Budget → N Kanäle ≈ N× Durchsatz, jeder Kanal bleibt unter dem Per-Route-Limit.
|
||||||
- [x] 429 sperrt **nur den betroffenen Kanal** kurz, nicht alle.
|
- [ ] 429 sperrt **nur den betroffenen Kanal** kurz, nicht alle.
|
||||||
|
|
||||||
### 2c. Betrieb
|
### 2c. Betrieb
|
||||||
- [ ] Bei eigenen IPs prüfen: sind es echte getrennte Egress-IPs (multi-homed), nicht NAT hinter einer.
|
- [ ] Bei eigenen IPs prüfen: sind es echte getrennte Egress-IPs (multi-homed), nicht NAT hinter einer.
|
||||||
@@ -144,10 +123,10 @@ Auf Wunsch integriert, bewusst opt-in:
|
|||||||
(welcher Subgraph deckt Fills sauber ab?), dann als `IHistoricalTradeSource`.
|
(welcher Subgraph deckt Fills sauber ab?), dann als `IHistoricalTradeSource`.
|
||||||
|
|
||||||
## 5. Tests / Abnahme
|
## 5. Tests / Abnahme
|
||||||
- [x] Per-Kanal-Limiter: unabhängige Budgets (kein globales Blocken); 429 sperrt nur einen Kanal.
|
- [ ] Per-Kanal-Limiter: unabhängige Budgets (kein globales Blocken); 429 sperrt nur einen Kanal.
|
||||||
- [x] Egress-Binding: Smoke gegen einen Dienst, der die Quell-IP zurückgibt → Round-Robin nutzt
|
- [ ] Egress-Binding: Smoke gegen einen Dienst, der die Quell-IP zurückgibt → Round-Robin nutzt
|
||||||
wirklich verschiedene IPs; Proxy-Kanal geht über den Proxy.
|
wirklich verschiedene IPs; Proxy-Kanal geht über den Proxy.
|
||||||
- [x] Kanal-Health: toter Proxy wird automatisch pausiert, Pool weicht aus.
|
- [ ] Kanal-Health: toter Proxy wird automatisch pausiert, Pool weicht aus.
|
||||||
- [ ] Blockchain-Quelle: Stichproben-Abgleich Bulk-Historie ↔ REST-`/activity` eines Wallets,
|
- [ ] Blockchain-Quelle: Stichproben-Abgleich Bulk-Historie ↔ REST-`/activity` eines Wallets,
|
||||||
bevor sie produktiv wird.
|
bevor sie produktiv wird.
|
||||||
- [ ] **Header-Rotation:** bei `Enabled=false` wird genau ein konsistenter Standard-Satz gesendet
|
- [ ] **Header-Rotation:** bei `Enabled=false` wird genau ein konsistenter Standard-Satz gesendet
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
# Analyse: Linux-Fähigkeit von Predictalytics
|
|
||||||
|
|
||||||
**Stand:** 2026-08-06 · Branch `main` @ `aa19a89` · reine Ist-Analyse, keine Codeänderungen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Kernbefund vorab
|
|
||||||
|
|
||||||
> **Avalonia UI und LiveCharts2 werden nicht benötigt.**
|
|
||||||
|
|
||||||
Die Annahme im Auftrag war, dass eine WinForms-Oberfläche auf Avalonia + LiveCharts2 portiert
|
|
||||||
werden muss. Das trifft auf dieses Projekt nicht zu:
|
|
||||||
|
|
||||||
* Die **eigentliche Analyse-Oberfläche ist bereits eine Browser-Anwendung** —
|
|
||||||
`src/Predictalytics.Api/wwwroot/` (3.745 Zeilen HTML/CSS/JS), Charts über
|
|
||||||
**Chart.js 4.4.4** (`index.html:9`, CDN). Sie ist damit heute schon plattformunabhängig.
|
|
||||||
* `Predictalytics.WinFormsHost` ist **kein UI-Projekt im fachlichen Sinne**, sondern eine
|
|
||||||
Bedien-Hülle für den Betrieb: Start/Stop der Worker, Start/Stop des Kestrel-Webservers,
|
|
||||||
ein `PropertyGrid` für Einstellungen, eine `RichTextBox` als Log-Terminal und vier
|
|
||||||
manuelle Wartungsaktionen. **1.698 LOC inkl. Designer-Datei.** Kein einziges Diagramm.
|
|
||||||
|
|
||||||
Die Aufgabe lautet damit nicht „UI-Framework migrieren", sondern
|
|
||||||
**„Bedien-Hülle durch einen headless Host ersetzen"** — eine Größenordnung weniger Aufwand.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Portierungsstatus je Projekt
|
|
||||||
|
|
||||||
| Projekt | TFM | Status | Bemerkung |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Predictalytics.Domain` | `net8.0` | ✅ portabel | 42 Dateien, reine Fachlogik |
|
|
||||||
| `Predictalytics.Application` | `net8.0` | ✅ portabel | 39 Dateien |
|
|
||||||
| `Predictalytics.Infrastructure` | `net8.0` | ✅ portabel | 94 Dateien, EF Core + Pomelo MySQL |
|
|
||||||
| `Predictalytics.Api` | `net8.0` | ✅ portabel | Minimal-APIs + wwwroot |
|
|
||||||
| `Predictalytics.Worker` | `net8.0` | ✅ portabel | 13 Worker-Services |
|
|
||||||
| `Predictalytics.Application.Tests` | `net10.0` | ⚠️ Inkonsistenz | s. Punkt 4.8 |
|
|
||||||
| `Predictalytics.WinFormsHost` | `net8.0-windows` | ❌ **muss ersetzt werden** | einziger Blocker |
|
|
||||||
| `LicenseLabrador.Client` (Schwester-Repo) | `netstandard2.0` | ⚠️ teilportabel | s. Punkt 3.2 |
|
|
||||||
|
|
||||||
**54.056 Zeilen C# gesamt. Davon sind 1.698 (3,1 %) windows-gebunden — und davon ist
|
|
||||||
etwa die Hälfte (`EmbeddedWebServer.cs`, Serilog-Setup) inhaltlich bereits portabel und
|
|
||||||
nur im falschen Projekt beheimatet.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Was bereits sauber ist (und keinen Aufwand erzeugt)
|
|
||||||
|
|
||||||
Diese Punkte wurden explizit geprüft, weil sie erfahrungsgemäß die teuren Überraschungen
|
|
||||||
einer Linux-Portierung sind. Der Code ist hier durchweg in gutem Zustand:
|
|
||||||
|
|
||||||
### 2.1 Zeitzonen — überwiegend sauber, mit einer wichtigen Ausnahme ⚠️
|
|
||||||
|
|
||||||
> **Korrektur (2026-08-06, nach Phase 0):** Die erste Fassung dieser Analyse stufte den
|
|
||||||
> Punkt als „vollständig sauber, Aufwand 0" ein. Das war falsch — siehe unten. Die
|
|
||||||
> explizite Zeitzonen-*API*-Nutzung ist sauber, die *implizite* über `DateTime.TryParse`
|
|
||||||
> war es nicht.
|
|
||||||
|
|
||||||
```
|
|
||||||
166 × DateTime.UtcNow / DateTimeOffset.UtcNow
|
|
||||||
0 × DateTime.Now · DateTime.Today · DateTimeOffset.Now
|
|
||||||
0 × ToLocalTime() · DateTimeKind.Local
|
|
||||||
0 × TimeZoneInfo / FindSystemTimeZoneById
|
|
||||||
13 × DateTime.TryParse ohne Culture/Styles ← Problem
|
|
||||||
```
|
|
||||||
|
|
||||||
Der klassische Linux-Killer `TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time")`
|
|
||||||
(Windows-IDs existieren unter Linux nicht, dort gilt IANA `Europe/Berlin`) **kommt nicht
|
|
||||||
vor**. Insoweit ist die Codebasis sauber.
|
|
||||||
|
|
||||||
**Übersehen hatte ich die implizite Variante:** `DateTime.TryParse("...Z")` ohne
|
|
||||||
`DateTimeStyles` liefert `Kind=Local` und **rechnet den Wert in Lokalzeit um**. In beiden
|
|
||||||
Providern wurden so ISO-Zeitstempel der Plattform-APIs eingelesen:
|
|
||||||
|
|
||||||
* `PolymarketProvider.cs` — `StartDate`, `EndDate`, `CreatedAt`, `ClosedAt` (10 Stellen)
|
|
||||||
* `LimitlessProvider.cs` — `ExecutedAt`, `ExpirationDate` (2 Stellen)
|
|
||||||
|
|
||||||
Dass UTC gemeint war, belegen die Fallbacks: jede dieser Zuweisungen fällt bei
|
|
||||||
Parse-Fehler auf `DateTime.UtcNow` zurück, und direkt daneben steht `DbCreatedAt =
|
|
||||||
DateTime.UtcNow`. Auf der Berliner Entwicklungsmaschine landeten die geparsten Werte
|
|
||||||
dadurch um 1–2 Stunden verschoben in der Datenbank, die Fallback-Werte dagegen korrekt.
|
|
||||||
|
|
||||||
Für die Portierung ist das doppelt relevant: derselbe Code hätte auf einem Linux-Server
|
|
||||||
mit `TZ=UTC` **andere Werte** produziert als auf dem Windows-Rechner.
|
|
||||||
|
|
||||||
**Behoben in Phase 0** über `Infrastructure/Helpers/DateParsing.cs`
|
|
||||||
(`InvariantCulture` + `AdjustToUniversal | AssumeUniversal`).
|
|
||||||
|
|
||||||
⚠️ **Offen: Altdaten.** Bereits gespeicherte Zeilen tragen die Verschiebung weiter. Ob ein
|
|
||||||
Backfill nötig ist, hängt daran, wie stark `StartDate`/`EndDate`/`ClosedAt` in die
|
|
||||||
Auswertung eingehen — siehe „Nächste Schritte" im Portierungsplan.
|
|
||||||
|
|
||||||
### 2.2 Kulturabhängige Zahlenformatierung — bis auf eine Stelle sauber ✅
|
|
||||||
|
|
||||||
Alle Parser für Fremd-APIs (Polymarket, Limitless) verwenden explizit `InvariantCulture`:
|
|
||||||
|
|
||||||
* `LimitlessProvider.cs:59,60,110,113,114,115`
|
|
||||||
* `PolymarketModels.cs:17` · `PolymarketProvider.cs:386`
|
|
||||||
* `TradeHistoryWorker.cs:466,467`
|
|
||||||
|
|
||||||
**Eine Ausnahme (echter Latent-Bug, plattformunabhängig):**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs:268
|
|
||||||
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
|
|
||||||
```
|
|
||||||
|
|
||||||
Ohne `InvariantCulture`. Auf einem System mit deutscher Locale (`de_DE.UTF-8`, unter Linux
|
|
||||||
sehr üblich) wird `"1234.56"` als **123456** interpretiert — Faktor 100.000 zu hoch.
|
|
||||||
Der Fehler existiert schon heute auf einem deutschsprachigen Windows; Linux macht ihn nur
|
|
||||||
wahrscheinlicher, weil die Locale dort oft explizit gesetzt wird.
|
|
||||||
|
|
||||||
**Aufwand: 5 Minuten. Sollte unabhängig von der Portierung gefixt werden.**
|
|
||||||
|
|
||||||
Zusätzliche Absicherung empfohlen: `InvariantGlobalization` bzw. ein explizites
|
|
||||||
`CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture` im neuen Host.
|
|
||||||
|
|
||||||
### 2.3 Pfade und Dateisystem ✅
|
|
||||||
|
|
||||||
* Keine hartkodierten Laufwerksbuchstaben im Code — **eine** Ausnahme, siehe 4.5.
|
|
||||||
* `Path.Combine()` wird konsequent verwendet, keine manuell zusammengesetzten `\`-Pfade.
|
|
||||||
* **Kein einziger Datei-/Verzeichniszugriff** in `Infrastructure`, `Worker`, `Api`,
|
|
||||||
`Application`, `Domain`. Sämtliches File-IO liegt in `WinFormsHost` (Settings, Logs)
|
|
||||||
und im Lizenz-Client.
|
|
||||||
|
|
||||||
### 2.4 Groß-/Kleinschreibung (case-sensitive Dateisystem) ✅
|
|
||||||
|
|
||||||
Linux-Dateisysteme sind case-sensitiv, Windows nicht — ein häufiger Stolperstein bei
|
|
||||||
statischen Web-Assets. Geprüft:
|
|
||||||
|
|
||||||
| Referenz | Datei | Passt |
|
|
||||||
|---|---|---|
|
|
||||||
| `href="css/style.css?v=20260715"` | `wwwroot/css/style.css` | ✅ |
|
|
||||||
| `src="js/app.js?v=20260715"` | `wwwroot/js/app.js` | ✅ |
|
|
||||||
| `href="./docs.html"` | `wwwroot/docs.html` | ✅ |
|
|
||||||
| `href="./index.html"` | `wwwroot/index.html` | ✅ |
|
|
||||||
|
|
||||||
Alles durchgängig kleingeschrieben und deckungsgleich. **Aufwand: 0.**
|
|
||||||
|
|
||||||
Hinweis: MySQL-Tabellennamen sind unter Linux abhängig von `lower_case_table_names`
|
|
||||||
ebenfalls case-sensitiv. Da Prod-DB und Dev-DB bereits auf Linux-Servern laufen
|
|
||||||
(`lqf7.your-database.de`) und der Code über EF Core mit konsistenter Schreibweise
|
|
||||||
zugreift, besteht hier kein zusätzliches Risiko durch die Client-Portierung.
|
|
||||||
|
|
||||||
### 2.5 Keine Windows-spezifischen APIs außerhalb des Hosts ✅
|
|
||||||
|
|
||||||
Geprüft auf `Registry`, `Microsoft.Win32`, `ManagementObject`/WMI, `ProtectedData`,
|
|
||||||
`DllImport`, `System.Drawing`, `Environment.OSVersion`: **keine Treffer** außerhalb von
|
|
||||||
`Predictalytics.WinFormsHost`. Auch `Encoding.Default` (unter Linux immer UTF-8, unter
|
|
||||||
Windows Codepage) kommt nirgends vor.
|
|
||||||
|
|
||||||
### 2.6 NuGet-Abhängigkeiten ✅
|
|
||||||
|
|
||||||
Alle verwendeten Pakete sind plattformneutral: EF Core 8 + Pomelo MySQL, Serilog (+ File/
|
|
||||||
Console/Compact), Swashbuckle, `Microsoft.Extensions.*`, xunit, Moq. Keine
|
|
||||||
windows-exklusiven Pakete außerhalb des WinForms-Hosts.
|
|
||||||
|
|
||||||
### 2.7 Ein bereits vorhandener Vorteil: der Composition Root existiert schon ✅
|
|
||||||
|
|
||||||
`Predictalytics.Api/ApiConfiguration.cs` (`ConfigureApi`) ist eine **vollständige,
|
|
||||||
eigenständige Host-Konfiguration** — DI, CORS, Swagger, Static Files, Endpoint-Mapping.
|
|
||||||
Ihr fehlt nur eine `Main`-Methode. Der neue headless Host kann darauf direkt aufsetzen,
|
|
||||||
statt die Verdrahtung neu zu schreiben.
|
|
||||||
|
|
||||||
Ebenso ist `Infrastructure/Logging/RichTextBoxSink.cs` trotz des Namens **vollständig
|
|
||||||
portabel** — es ist ein reiner Delegate-Sink (`Action<string, LogEventLevel>`) ohne
|
|
||||||
WinForms-Referenz. Lediglich umbenennen (z. B. `DelegateSink`) wäre sinnvoll.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Die tatsächlichen Blocker
|
|
||||||
|
|
||||||
### 3.1 `Predictalytics.WinFormsHost` — Ersatz durch headless Host ⛔ **Hauptaufwand**
|
|
||||||
|
|
||||||
Das Projekt ist der einzige Einstiegspunkt der Anwendung (`static void Main` existiert nur
|
|
||||||
hier). Es leistet fünf Dinge, die im neuen Host anders gelöst werden müssen:
|
|
||||||
|
|
||||||
| Funktion heute | Ersatz unter Linux | Aufwand |
|
|
||||||
|---|---|---|
|
|
||||||
| **Serilog-Setup** (`Program.cs:35-148`) — Datei-Sinks nach Level und Plattform | 1:1 übernehmbar, ist bereits portabel. Nur `RichTextBoxSink` entfällt bzw. wird Console-Sink. | trivial |
|
|
||||||
| **`EmbeddedWebServer.cs`** (365 Z.) — Kestrel- und Worker-Lifecycle | Inhaltlich portabel. Wird zum normalen `IHost` mit `WebApplication` + `AddWorkerServices()`. Vereinfacht sich sogar deutlich, weil Start/Stop-Buttons entfallen. | klein |
|
|
||||||
| **`AppSettings.cs` + `PropertyGrid`** — Laufzeit-Konfiguration in `settings.json` | `appsettings.json` + Umgebungsvariablen + `IOptions<T>`. Die `[Category]`/`[DisplayName]`-Attribute entfallen ersatzlos. | klein–mittel |
|
|
||||||
| **Manuelle Aktionen** — „Sync Markets", „DB Update", „Recalculate All Traders" (Bestätigungsdialoge + `MessageBox`) | Entweder CLI-Verben (`--sync-markets`, `--recalc-all`) oder geschützte Admin-Endpunkte in der bestehenden Web-UI. Die Rückfrage-Logik („nicht während laufender Worker") muss erhalten bleiben. | mittel |
|
|
||||||
| **Log-Terminal** (`RichTextBox`) + DB-Größen-Anzeige + Build-Version | Entfällt / Console + `journalctl`. Die DB-Größenabfrage (`MainForm.cs:317-343`) kann als Health-Endpunkt weiterleben. | klein |
|
|
||||||
|
|
||||||
`WatchdogHeartbeatService.cs` (141 Z.) ist reines `HttpClient`-Verhalten und **vollständig
|
|
||||||
portabel** — sollte nach `Infrastructure` oder in den neuen Host wandern.
|
|
||||||
|
|
||||||
**Empfehlung:** WinFormsHost **nicht löschen**, sondern beide Hosts parallel führen.
|
|
||||||
Er ist die etablierte Betriebsoberfläche unter Windows; beide teilen sich denselben
|
|
||||||
Composition Root. Der Wartungsaufwand für die dünne Hülle ist gering.
|
|
||||||
|
|
||||||
### 3.2 Lizenzierung — der unterschätzte Blocker ⛔
|
|
||||||
|
|
||||||
Der `LicenseLabrador.Client` ist erfreulich gut vorbereitet, aber die **Aktivierung** ist es nicht.
|
|
||||||
|
|
||||||
**Was bereits funktioniert:**
|
|
||||||
|
|
||||||
* `HardwareId.cs:52-84` behandelt Linux explizit: liest `/etc/machine-id` bzw.
|
|
||||||
`/var/lib/dbus/machine-id`, mit MAC-Adressen-Fallback. Sauber gelöst.
|
|
||||||
* Zielframework `netstandard2.0`, `Microsoft.Win32.Registry` wird per `IsWindows()`-Guard
|
|
||||||
nur unter Windows angefasst.
|
|
||||||
|
|
||||||
**Was nicht funktioniert:**
|
|
||||||
|
|
||||||
1. **`LicenseDialog` ist ein blockierender WinForms-Modaldialog.**
|
|
||||||
`Program.cs:16` → `LicenseGuard.EnsureLicensed()` → `dialog.ShowDialog()`.
|
|
||||||
Auf einem headless Server gibt es niemanden, der einen Key eintippt. Es braucht einen
|
|
||||||
**nicht-interaktiven Aktivierungspfad**: Lizenzschlüssel aus Umgebungsvariable oder
|
|
||||||
Konfigurationsdatei, Aktivierung beim ersten Start, sauberer Abbruch mit Exit-Code und
|
|
||||||
klarer Logmeldung, wenn keine gültige Lizenz vorliegt.
|
|
||||||
|
|
||||||
2. **`StateStore.cs:43,92` — DPAPI-Verschlüsselung fällt unter Linux still auf Klartext zurück.**
|
|
||||||
```csharp
|
|
||||||
try { dataToSave = ProtectedData.Protect(plainBytes, null, DataProtectionScope.CurrentUser); }
|
|
||||||
catch { dataToSave = plainBytes; } // ← Linux landet immer hier
|
|
||||||
```
|
|
||||||
Der Lizenz-Cache (inkl. Lizenzschlüssel) liegt dann unverschlüsselt auf der Platte.
|
|
||||||
Funktional unkritisch, aber eine bewusste Entscheidung — mindestens sollten die
|
|
||||||
Dateirechte (`0600`) und das Verzeichnis abgesichert werden.
|
|
||||||
|
|
||||||
3. **`LicenseConfig.StorageDirectory`** nutzt `Environment.SpecialFolder.ApplicationData`
|
|
||||||
→ unter Linux `~/.config`. Unter systemd mit `DynamicUser=yes` oder ohne `$HOME`
|
|
||||||
existiert das nicht. Muss explizit gesetzt werden (z. B. `/var/lib/predictalytics`).
|
|
||||||
|
|
||||||
4. **`LicenseGuard.StartPeriodicRevalidation`** verwendet `System.Windows.Forms.Timer`
|
|
||||||
→ `PeriodicTimer` oder `IHostedService`. `Application.Exit()` → `IHostApplicationLifetime.StopApplication()`.
|
|
||||||
|
|
||||||
**Das ist der Punkt mit der größten Restunsicherheit**, weil er das Schwester-Repo
|
|
||||||
LicenseLabrador berührt und serverseitig geprüft werden muss (Aktivierung ohne UI).
|
|
||||||
|
|
||||||
### 3.3 Kestrel bindet nur auf `localhost` ⚠️
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// EmbeddedWebServer.cs:122
|
|
||||||
builder.WebHost.UseUrls($"http://localhost:{port}");
|
|
||||||
```
|
|
||||||
|
|
||||||
Das ist für einen Desktop-Host korrekt, für einen Linux-Server aber falsch: von außen
|
|
||||||
nicht erreichbar. Muss konfigurierbar werden (`0.0.0.0` bzw. `ASPNETCORE_URLS`), inkl.
|
|
||||||
Anpassung der CORS-`AllowedOrigins` (heute fest `http://localhost:5000`).
|
|
||||||
|
|
||||||
**Sicherheitshinweis:** Sobald der Dienst nicht mehr nur auf `localhost` lauscht, ist die
|
|
||||||
bisher implizite Absicherung weg. `MapPredictalyticsControlEndpoints()` (schreibende
|
|
||||||
Operationen) und Swagger sind dann exponiert. Es gibt bereits ein `CanControl`/`AuthRequired`-
|
|
||||||
Konzept (`ApiConfiguration.cs:66`) — das muss vor dem Linux-Deployment scharf geschaltet
|
|
||||||
werden, plus Reverse Proxy (nginx/Caddy) mit TLS davor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Weitere Fundstellen (klein, aber notwendig)
|
|
||||||
|
|
||||||
### 4.1 `Process.Start("explorer.exe", …)` — 3 Stellen
|
|
||||||
`MainForm.cs:192,207,209` (Browser öffnen, Log-Ordner öffnen). Betrifft nur den
|
|
||||||
WinForms-Host; im headless Host entfallen die Aktionen ersatzlos.
|
|
||||||
|
|
||||||
### 4.2 `System.Windows.Forms.Timer` — 2 Stellen
|
|
||||||
`MainForm.cs:71` (DB-Größe), `LicenseGuard.cs:70` (Revalidierung).
|
|
||||||
→ `PeriodicTimer` / `IHostedService`.
|
|
||||||
|
|
||||||
### 4.3 Schriftart `Cascadia Code`
|
|
||||||
`MainForm.cs:25`. Nur WinForms-relevant, entfällt.
|
|
||||||
|
|
||||||
### 4.4 Log-Verzeichnis
|
|
||||||
`Program.cs:33`: `Path.Combine(AppContext.BaseDirectory, "logs")`.
|
|
||||||
Technisch portabel, aber unter Linux ist das Installationsverzeichnis üblicherweise nicht
|
|
||||||
beschreibbar. → konfigurierbar machen, Default `/var/log/predictalytics` bzw.
|
|
||||||
`journalctl` über den Console-Sink.
|
|
||||||
|
|
||||||
Nebenbefund: `src/Predictalytics.WinFormsHost/logs/Predictalytics-20260428.json` ist
|
|
||||||
offenbar **versehentlich eingecheckt** und enthält Stacktraces mit lokalen Pfaden. Sollte
|
|
||||||
aus der Versionierung entfernt und `.gitignore` ergänzt werden.
|
|
||||||
|
|
||||||
### 4.5 `FindWwwrootPath()` — hartkodierter Absolutpfad ⚠️
|
|
||||||
```csharp
|
|
||||||
// EmbeddedWebServer.cs:314-321
|
|
||||||
var candidates = new[] {
|
|
||||||
Path.Combine(baseDir, "wwwroot"),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDir, "..","..","..","..","Predictalytics.Api","wwwroot")),
|
|
||||||
Path.GetFullPath(Path.Combine(baseDir, "..","..","..","..","..","src","Predictalytics.Api","wwwroot")),
|
|
||||||
@"j:\Softwareprojekte\Predictalytics\Predictalytics\src\Predictalytics.Api\wwwroot" // ←
|
|
||||||
};
|
|
||||||
```
|
|
||||||
Die Suchheuristik über Build-Output-Verzeichnisse funktioniert im Deployment nicht
|
|
||||||
zuverlässig, der `j:\`-Fallback gar nicht. Sauber: `wwwroot` als `Content` mit
|
|
||||||
`CopyToOutputDirectory` in die Host-`.csproj` aufnehmen (in der Api-`.csproj` bereits
|
|
||||||
vorhanden) und nur noch `Path.Combine(AppContext.BaseDirectory, "wwwroot")` verwenden.
|
|
||||||
|
|
||||||
### 4.6 MySQL-Verbindung: `SslMode = None` fest verdrahtet ⚠️
|
|
||||||
`AppSettings.cs:100`. Bei einem lokal laufenden Windows-Client zu einer entfernten DB war
|
|
||||||
das schon fragwürdig; bei einem Serverbetrieb sollte SSL konfigurierbar (und
|
|
||||||
standardmäßig aktiv) sein. Kein Linux-spezifisches Problem, fällt aber bei der
|
|
||||||
Konfigurationsmigration ohnehin an.
|
|
||||||
|
|
||||||
### 4.7 Egress-Kanäle (`SourceIp`) — verhält sich unter Linux anders ⚠️
|
|
||||||
`EgressPoolService.cs:70-88` bindet einen Socket per `socket.Bind(new IPEndPoint(ip, 0))`
|
|
||||||
an eine Quell-IP. Das funktioniert unter Linux grundsätzlich, aber:
|
|
||||||
* Die IP muss auf einem Interface tatsächlich konfiguriert sein (sonst `EADDRNOTAVAIL`);
|
|
||||||
Windows ist hier toleranter.
|
|
||||||
* Für nicht-lokale IPs bräuchte es `IP_FREEBIND`/`CAP_NET_ADMIN`.
|
|
||||||
|
|
||||||
Falls Egress-Kanäle produktiv genutzt werden, ist das ein expliziter Testpunkt.
|
|
||||||
Proxy-Kanäle (`WebProxy`) sind unproblematisch.
|
|
||||||
|
|
||||||
### 4.8 Zielframework-Inkonsistenz ⚠️
|
|
||||||
`Predictalytics.Application.Tests` steht auf **`net10.0`**, alle anderen auf `net8.0`.
|
|
||||||
Das funktioniert (Test-Assembly darf höher liegen), erzwingt aber ein .NET-10-SDK auf dem
|
|
||||||
Build-Server. Bei einer CI-Einrichtung für Linux vereinheitlichen — entweder alles auf
|
|
||||||
`net8.0` oder ein bewusster Sprung der gesamten Solution auf `net10.0`.
|
|
||||||
|
|
||||||
### 4.9 `Predictalytics.slnx` und Build
|
|
||||||
Die Solution referenziert den WinFormsHost. Auf Linux schlägt ein `dotnet build` der
|
|
||||||
Gesamtsolution fehl (`net8.0-windows` + `UseWindowsForms`). Optionen:
|
|
||||||
* Den WinFormsHost mit `<TargetFrameworks>` bzw. einer Condition auf
|
|
||||||
`$([MSBuild]::IsOSPlatform('Windows'))` versehen, oder
|
|
||||||
* eine zweite Solution-Datei für Linux-Builds pflegen.
|
|
||||||
|
|
||||||
Zusätzlich: Die `.csproj` des Hosts referenziert das Schwester-Repo über einen
|
|
||||||
**relativen Pfad mit vier Ebenen nach oben** (`..\..\..\..\LicenseLabrador\…`). Das setzt
|
|
||||||
eine feste Verzeichnisstruktur voraus und ist für CI/Container fragil — besser über ein
|
|
||||||
NuGet-Paket oder ein Git-Submodul lösen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Was zusätzlich neu entsteht (Deployment)
|
|
||||||
|
|
||||||
Kein Portierungsproblem im engeren Sinn, aber Teil der Aufgabe „Linux-fähig":
|
|
||||||
|
|
||||||
* **systemd-Unit** (`Restart=always`, `User=`, `WorkingDirectory=`, `EnvironmentFile=`)
|
|
||||||
oder alternativ **Dockerfile** + `docker-compose` (es existiert bereits ein
|
|
||||||
`J:\Softwareprojekte\dockerCompose`-Verzeichnis — vermutlich anknüpfbar).
|
|
||||||
* **Reverse Proxy** (nginx/Caddy) mit TLS vor Kestrel.
|
|
||||||
* **Secrets-Handling**: DB-Passwort und Watchdog-API-Key liegen heute im Klartext in
|
|
||||||
`settings.json` neben der Exe. Unter Linux → Environment-File mit `0600` oder
|
|
||||||
Secret-Store.
|
|
||||||
*Randnotiz:* In `LicenseGuard.cs:16-17` stehen Basic-Auth-Zugangsdaten im Quelltext.
|
|
||||||
Bei einem Server-Deployment (und erst recht bei der angedachten Monetarisierung) sollte
|
|
||||||
das überdacht werden — unabhängig von dieser Portierung.
|
|
||||||
* **EF-Migrationen**: `EnsureDatabaseAsync()` existiert bereits und wird beim Start
|
|
||||||
aufgerufen — für den Serverbetrieb ist zu entscheiden, ob Migrationen automatisch
|
|
||||||
laufen sollen oder als separater Schritt.
|
|
||||||
* **CI**: Build/Test auf Linux, Publish als `linux-x64` (ggf. self-contained).
|
|
||||||
* `docs/BETRIEB-Watchdog-Lizenz.md` muss um den Linux-Betrieb ergänzt werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Aufwandsschätzung
|
|
||||||
|
|
||||||
| Arbeitspaket | Aufwand (PT) | Risiko |
|
|
||||||
|---|---|---|
|
|
||||||
| **A** Neues Host-Projekt `Predictalytics.Host` (net8.0): Serilog, Kestrel, Worker, Lifecycle | 2–3 | niedrig — Composition Root existiert bereits |
|
|
||||||
| **B** Konfigurationsmigration `AppSettings`/`PropertyGrid` → `appsettings.json` + Env + `IOptions` | 1 | niedrig |
|
|
||||||
| **C** Manuelle Wartungsaktionen → CLI-Verben bzw. Admin-Endpunkte | 1–1,5 | niedrig |
|
|
||||||
| **D** Lizenzierung headless: nicht-interaktive Aktivierung, StorageDirectory, Timer, Cache-Absicherung | 1,5–3 | **mittel–hoch** — berührt LicenseLabrador, serverseitig zu verifizieren |
|
|
||||||
| **E** Kestrel-Binding + CORS + Absicherung der Control-Endpunkte | 0,5–1 | mittel (Sicherheitsentscheidung nötig) |
|
|
||||||
| **F** Kleinkram: `wwwroot`-Deployment, Log-Pfad, Culture-Bug, TFM-Vereinheitlichung, Solution-Split, eingecheckte Logs | 0,5–1 | niedrig |
|
|
||||||
| **G** Deployment: systemd/Docker, Reverse Proxy, Secrets, CI | 1–2 | niedrig–mittel |
|
|
||||||
| **H** Verifikation auf einer echten Linux-Instanz (Worker-Dauerlauf, DB, Egress, Watchdog, Lizenz) | 2 | mittel |
|
|
||||||
| **Summe** | **~9,5–14,5 PT** | |
|
|
||||||
|
|
||||||
**Realistisch: 2–3 Wochen bei Teilzeit, ca. 2 Wochen fokussiert.**
|
|
||||||
|
|
||||||
Zum Vergleich: Der ursprünglich angenommene Weg über **Avalonia + LiveCharts2** hätte
|
|
||||||
grob **4–8 Wochen** gekostet — für eine Oberfläche, die es fachlich bereits als Web-UI
|
|
||||||
gibt. Diese Arbeit wäre vollständig verloren gewesen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Empfohlenes Vorgehen
|
|
||||||
|
|
||||||
1. **F zuerst** (halber Tag): Culture-Bug, TFM, eingecheckte Logs, `wwwroot`-Deployment.
|
|
||||||
Wirkt sofort, unabhängig von der Portierung, reduziert Rauschen.
|
|
||||||
2. **A + B parallel** — das neue Host-Projekt entsteht neben dem WinFormsHost, nicht
|
|
||||||
an seiner Stelle. Beide teilen sich `ApiConfiguration` und `AddWorkerServices()`.
|
|
||||||
Ab hier läuft der Stack unter Linux (noch ohne Lizenz-Gate).
|
|
||||||
3. **E** — Binding und Absicherung, bevor irgendetwas exponiert wird.
|
|
||||||
4. **D** — Lizenzierung. Am besten früh anfangen zu klären, weil es das Schwester-Repo
|
|
||||||
und ggf. Serveränderungen betrifft; die Umsetzung kann dann parallel laufen.
|
|
||||||
5. **C**, **G**, **H** zum Abschluss.
|
|
||||||
|
|
||||||
**Zwei Entscheidungen sind vorab zu treffen:**
|
|
||||||
|
|
||||||
* **Bleibt der WinFormsHost erhalten?** Empfehlung: **ja**, als Windows-Betriebsoberfläche.
|
|
||||||
Er ist dünn, funktioniert und beide Hosts teilen sich denselben Kern. Der Web-UI-Zugang
|
|
||||||
ist ohnehin identisch.
|
|
||||||
* **Wie wird headless lizenziert?** Lizenzschlüssel per Umgebungsvariable/Config und
|
|
||||||
Aktivierung beim ersten Start ist der pragmatische Weg — muss aber mit LicenseLabrador
|
|
||||||
abgestimmt werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Zusammenfassung in drei Sätzen
|
|
||||||
|
|
||||||
Predictalytics ist in deutlich besserem Zustand für eine Linux-Portierung als erwartet:
|
|
||||||
96,9 % des Codes sind bereits plattformneutral, es gibt keine Windows-Zeitzonen-IDs und
|
|
||||||
keine Windows-APIs außerhalb des Hosts, und die Analyse-Oberfläche ist längst eine
|
|
||||||
Browser-Anwendung mit Chart.js — LiveCharts2 wird nicht gebraucht.
|
|
||||||
|
|
||||||
Zwei Defekte an der Grenze zu den Plattform-APIs (13 × lokalzeit-verzerrendes
|
|
||||||
Datums-Parsing, 1 × kulturabhängiges Zahlen-Parsing) waren allerdings vorhanden und sind
|
|
||||||
in Phase 0 behoben.
|
|
||||||
|
|
||||||
Der eigentliche Aufwand liegt im Ersetzen der 1.698 Zeilen umfassenden WinForms-
|
|
||||||
Bedienhülle durch einen headless Host und im Umbau der Lizenzaktivierung, die heute
|
|
||||||
zwingend einen Modaldialog erfordert.
|
|
||||||
|
|
||||||
Geschätzt **9,5–14,5 Personentage**, mit dem einzigen nennenswerten Risiko im
|
|
||||||
Lizenzierungspfad.
|
|
||||||
@@ -1,766 +0,0 @@
|
|||||||
# Implementierungsplan: Predictalytics auf Windows + Linux
|
|
||||||
|
|
||||||
**Stand:** 2026-08-06 · Basis: [ANALYSE-Linux-Portierung.md](ANALYSE-Linux-Portierung.md)
|
|
||||||
**Ziel:** Ein Code-Stand, der unter Windows und Linux identisch läuft — Bedienhülle in
|
|
||||||
Avalonia, gesamte Solution auf .NET 10.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Drei Entscheidungen vorab — **alle getroffen (2026-08-06)**
|
|
||||||
|
|
||||||
| # | Entscheidung | Ergebnis |
|
|
||||||
|---|---|---|
|
|
||||||
| 0.1 | EF Core | **Weg A** — TFM `net10.0`, EF Core `9.0.18` + Pomelo `9.0.0` |
|
|
||||||
| 0.2 | UI-Framework | **Avalonia 12.1.1**, kein LiveCharts2 |
|
|
||||||
| 0.3 | Linux-Ziel | **Beides** — Avalonia-GUI *und* Headless-Modus (AP 3.4 verbindlich) |
|
|
||||||
|
|
||||||
### 0.1 ✅ EF Core: Version 9 behalten, nicht auf 10 gehen — **entschieden: Weg A**
|
|
||||||
|
|
||||||
Das ist der einzige Punkt, an dem „alles auf .NET 10" nicht sauber aufgeht.
|
|
||||||
|
|
||||||
**Befund (verifiziert über nuget.org am 2026-08-06):**
|
|
||||||
|
|
||||||
```
|
|
||||||
Pomelo.EntityFrameworkCore.MySql → neueste stabile Version: 9.0.0 (2025-08-17)
|
|
||||||
nuspec: targetFramework net8.0
|
|
||||||
Microsoft.EntityFrameworkCore.Relational [9.0.0, 9.0.999] ← harte Obergrenze
|
|
||||||
```
|
|
||||||
|
|
||||||
**Es gibt keinen Pomelo-Provider für EF Core 10.** Die Versionsspanne ist nach oben
|
|
||||||
geschlossen; ein Upgrade auf EF Core 10 ist mit Pomelo technisch unmöglich.
|
|
||||||
|
|
||||||
| Weg | Konsequenz |
|
|
||||||
|---|---|
|
|
||||||
| **A — empfohlen: TFM `net10.0`, EF Core `9.0.18`, Pomelo `9.0.0`** | Alle Projekte laufen auf .NET 10. EF-Core-9-Pakete (net8.0) laufen problemlos auf der .NET-10-Runtime. **Kein Provider-Wechsel, kein DB-Risiko.** Nur +1 EF-Major statt +2. |
|
|
||||||
| **B — EF Core `10.0.10` + `MySql.EntityFrameworkCore 10.0.9` (Oracle)** | Erzwingt den Wechsel des Datenbankproviders. Andere SQL-Generierung, andere Migrations-Historie, abweichendes Verhalten bei Typ-Mapping und Bulk-Operationen — **gegen die Produktiv-DB.** Realistisch +3–5 PT plus Regressionsrisiko in 94 Infrastructure-Dateien und 30+ Migrationen. |
|
|
||||||
|
|
||||||
**→ Gewählt: Weg A.** „Auf .NET 10 gleichbleiben" ist das eigentliche Ziel und wird
|
|
||||||
vollständig erreicht — das Zielframework aller Projekte ist `net10.0`. Dass die
|
|
||||||
EF-Core-*Bibliothek* bei 9.0.18 bleibt, ist ein Implementierungsdetail ohne Auswirkung auf
|
|
||||||
Runtime, Sprachfeatures oder Toolchain. Sobald Pomelo einen EF-Core-10-Provider
|
|
||||||
veröffentlicht, ist der Nachzug ein Einzeiler.
|
|
||||||
|
|
||||||
Weg B entfällt; ein Providerwechsel gegen die Produktiv-DB findet nicht statt.
|
|
||||||
|
|
||||||
### 0.2 Avalonia 12.1.1 — entschieden ✅
|
|
||||||
|
|
||||||
```
|
|
||||||
Avalonia 12.1.1 (2026-07-29), lib-TFMs: net8.0, net10.0 ← natives .NET 10
|
|
||||||
Avalonia.Desktop 12.1.1 · Avalonia.Themes.Fluent 12.1.1 · Avalonia.Controls.DataGrid 12.1.2
|
|
||||||
```
|
|
||||||
|
|
||||||
Eine Einschränkung: **`Avalonia.Diagnostics` liegt noch bei 11.3.18**, es gibt keine
|
|
||||||
12.x-Version. Das ist das DevTools-Inspektorfenster (F12) — reines Entwicklerwerkzeug, für
|
|
||||||
die Laufzeit irrelevant. Falls beim Layouten vermisst: temporär eine 11.3.18-Referenz nur
|
|
||||||
in der Debug-Konfiguration, oder ohne auskommen.
|
|
||||||
|
|
||||||
**LiveCharts2 wird nicht benötigt.** Die Bedienhülle enthält kein einziges Diagramm; alle
|
|
||||||
Charts liegen in der Web-UI (Chart.js). Keine Chart-Bibliothek im Shell-Projekt.
|
|
||||||
|
|
||||||
### 0.3 ✅ Wo läuft die Linux-Instanz? — **entschieden: beides**
|
|
||||||
|
|
||||||
Eine Avalonia-GUI braucht unter Linux eine Desktop-Umgebung: X11 (bzw. Wayland über XWayland),
|
|
||||||
dazu `libx11`, `libice`, `libsm`, `libfontconfig1` und installierte Fonts. Auf einem
|
|
||||||
**headless Server ohne Desktop startet sie nicht.**
|
|
||||||
|
|
||||||
**→ Gewählt: beides in einem Binary.** Die Avalonia-Shell für Desktop-Betrieb (Windows und
|
|
||||||
Linux), zusätzlich ein Schalter `--headless` für Server ohne Desktop-Session.
|
|
||||||
|
|
||||||
Damit ist **AP 3.4 verbindlicher Bestandteil**, nicht optional. Nach der
|
|
||||||
Hosting-Extraktion (Phase 2) kostet er ca. 0,5 PT, weil der gesamte Kern dann bereits
|
|
||||||
GUI-frei ist. Konsequenzen, die dadurch fix werden:
|
|
||||||
|
|
||||||
* Der Lizenzschlüssel muss **auch ohne Dialog** beziehbar sein (Konfiguration /
|
|
||||||
Umgebungsvariable) — koppelt an die laufende HardwareID-Arbeit.
|
|
||||||
* Das Deployment liefert zwei Betriebsarten: Desktop-Start und systemd/Container.
|
|
||||||
* Phase 6 verifiziert beide Modi.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Zielarchitektur
|
|
||||||
|
|
||||||
Der entscheidende Umbau ist nicht die GUI, sondern das **Herausziehen eines
|
|
||||||
plattformneutralen Hosting-Kerns**. Heute liegen ~870 portable Zeilen (EmbeddedWebServer,
|
|
||||||
Watchdog, Serilog-Setup, Settings) im windows-gebundenen Projekt fest. Danach ist die
|
|
||||||
GUI nur noch eine dünne Schale darüber — und der Headless-Modus fällt fast von selbst ab.
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
Predictalytics.Domain net10.0 unverändert
|
|
||||||
Predictalytics.Application net10.0 unverändert
|
|
||||||
Predictalytics.Infrastructure net10.0 unverändert
|
|
||||||
Predictalytics.Api net10.0 unverändert
|
|
||||||
Predictalytics.Worker net10.0 unverändert
|
|
||||||
Predictalytics.Hosting NEU net10.0 ← plattformneutraler Kern
|
|
||||||
Predictalytics.Shell NEU net10.0 ← Avalonia-Bedienhülle (Win + Linux)
|
|
||||||
Predictalytics.Application.Tests net10.0 net10.0 statt bisher net10.0 (bleibt)
|
|
||||||
Predictalytics.WinFormsHost net10.0-windows ← Übergang, s. 6.2
|
|
||||||
```
|
|
||||||
|
|
||||||
**`Predictalytics.Hosting`** (neu, GUI-frei) nimmt auf:
|
|
||||||
|
|
||||||
| Herkunft | Ziel |
|
|
||||||
|---|---|
|
|
||||||
| `WinFormsHost/Services/EmbeddedWebServer.cs` (365 Z.) | `Hosting/PredictalyticsHost.cs` — unverändert bis auf Binding + wwwroot |
|
|
||||||
| `WinFormsHost/AppSettings.cs` (137 Z.) | `Hosting/PredictalyticsOptions.cs` — ohne `[Category]`/`[Editor]`-Attribute |
|
|
||||||
| `WinFormsHost/Services/WatchdogHeartbeatService.cs` (141 Z.) | 1:1, ist bereits portabel |
|
|
||||||
| `WinFormsHost/Program.cs:33-148` (Serilog-Setup) | `Hosting/LoggingSetup.cs` — 1:1, ist bereits portabel |
|
|
||||||
| `WinFormsHost/Services/LicenseGuard.cs` (102 Z.) | `Hosting/LicenseGuard.cs` — ohne WinForms-Timer/Dialog |
|
|
||||||
| `Infrastructure/Logging/RichTextBoxSink.cs` | umbenennen → `DelegateSink.cs` (ist schon portabel) |
|
|
||||||
|
|
||||||
**`Predictalytics.Shell`** (neu, Avalonia) enthält nur noch View + ViewModel.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Phase 0 — Vorarbeiten ✅ **erledigt (2026-08-06)**
|
|
||||||
|
|
||||||
| # | Aufgabe | Ergebnis |
|
|
||||||
|---|---|---|
|
|
||||||
| 0.1 | `InvariantCulture` ergänzen | ✅ `LimitlessProvider.cs:270` (Volume) |
|
|
||||||
| 0.1b | **Nachtrag:** 13 × lokalzeit-verzerrendes `DateTime.TryParse` | ✅ neuer Helfer `Infrastructure/Helpers/DateParsing.cs`, alle Fundstellen umgestellt |
|
|
||||||
| 0.2 | Eingecheckte Logdatei entfernen | ✅ nicht nötig — Datei ist **nicht** getrackt, `.gitignore:14 logs/` greift bereits |
|
|
||||||
| 0.3 | `j:\`-Absolutpfad-Fallback entfernen | ✅ `EmbeddedWebServer.cs` |
|
|
||||||
| 0.4 | Branch anlegen | ✅ `feature/linux-net10` |
|
|
||||||
|
|
||||||
**Abnahme erreicht:** `dotnet build` → 0 Fehler, 10 Warnungen (alle vorbestehend).
|
|
||||||
`dotnet test` → **100 bestanden, 0 Fehler, 1 übersprungen**.
|
|
||||||
|
|
||||||
### Nachtrag 0.1b — Umfang und offener Punkt
|
|
||||||
|
|
||||||
`DateTime.TryParse("2026-08-06T12:00:00Z")` ohne `DateTimeStyles` liefert `Kind=Local` und
|
|
||||||
rechnet in Lokalzeit um. Betroffen waren in beiden Providern `StartDate`, `EndDate`,
|
|
||||||
`CreatedAt`, `ClosedAt`, `ExecutedAt`, `ExpirationDate` — während der Fallback derselben
|
|
||||||
Zuweisungen (`DateTime.UtcNow`) korrektes UTC schrieb. Auf der Berliner Maschine also
|
|
||||||
1–2 h Versatz, auf einem Linux-Server mit `TZ=UTC` hingegen nicht: derselbe Code hätte
|
|
||||||
plattformabhängig unterschiedliche Werte erzeugt.
|
|
||||||
|
|
||||||
✅ **Altdaten-Backfill: entfällt (Entscheidung 2026-08-06).** Der Fix wirkt nur für neue
|
|
||||||
Schreibvorgänge; bereits gespeicherte Zeilen tragen den 1–2-h-Versatz weiter. Bewusst
|
|
||||||
akzeptiert — die Daten verlieren mit steigendem Alter ohnehin an Relevanz, und die
|
|
||||||
betroffenen Felder werden bei jedem Markt-Sync überschrieben.
|
|
||||||
|
|
||||||
**Konsequenz für Phase 6:** Beim Vergleich Linux-Lauf ↔ Windows-Referenz (Prüfung 8.3)
|
|
||||||
dürfen historische Zeilen nicht als Referenz dienen — nur frisch geschriebene Datensätze
|
|
||||||
sind aussagekräftig.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Phase 1 — .NET 10 ✅ **erledigt (2026-08-06)**
|
|
||||||
|
|
||||||
**Abnahme erreicht:** `dotnet build` → 0 Fehler, 8 Warnungen (alle vorbestehend, 2 weniger
|
|
||||||
als vorher). `dotnet test` → **100 bestanden, 0 Fehler, 1 übersprungen**, Testhost
|
|
||||||
`net10.0`. Alle Projekte laufen auf `net10.0`, WinFormsHost auf `net10.0-windows`.
|
|
||||||
|
|
||||||
Ergebnis gegenüber der Planung:
|
|
||||||
|
|
||||||
| Erwartung | Tatsächlich |
|
|
||||||
|---|---|
|
|
||||||
| Swashbuckle 6.9 → 10.x „wahrscheinlichster Reibungspunkt", ggf. Wechsel auf `AddOpenApi()` | **Keine Bruchstelle.** Der genutzte API-Ausschnitt (`AddSwaggerGen`/`SwaggerDoc`/`UseSwagger`/`UseSwaggerUI`) ist stabil geblieben. Kein Umbau nötig. |
|
|
||||||
| EF Core 8 → 9 „überschaubar" | Bestätigt, keine Anpassung nötig |
|
|
||||||
| SQLite-Sicherheitswarnung „löst sich mit dem EF-Bump von selbst" | **Falsch** — EF Core 9.0.18 zieht SQLitePCLRaw 2.1.10, weiterhin von GHSA-2m69-gcr7-jv3q betroffen. Behoben über einen expliziten transitiven Pin auf 2.1.12. |
|
|
||||||
|
|
||||||
Bewusst **vor** dem Avalonia-Umbau erledigt: eine Baustelle nach der anderen, und das
|
|
||||||
Shell-Projekt entsteht direkt auf dem Zielframework.
|
|
||||||
|
|
||||||
### AP 1.1 — Zentrale Frameworkverwaltung ✅
|
|
||||||
`Directory.Build.props` setzt `TargetFramework=net10.0` zentral; `<TargetFramework>` aus
|
|
||||||
allen `.csproj` entfernt (Ausnahme: WinFormsHost → `net10.0-windows`). Zusätzlich zentral:
|
|
||||||
`SatelliteResourceLanguages=en`, das bisher in vier Projekten einzeln stand.
|
|
||||||
|
|
||||||
`Directory.Packages.props` mit `ManagePackageVersionsCentrally` eingeführt — 21 Pakete,
|
|
||||||
Versionen aus allen `.csproj` entfernt. Zusätzlich
|
|
||||||
`CentralPackageTransitivePinningEnabled`, um Sicherheits-Pins auf transitive
|
|
||||||
Abhängigkeiten setzen zu können.
|
|
||||||
|
|
||||||
**Nicht angefasst:** `Spike/Spike.csproj` — leeres Restprojekt ohne Quelldateien, nicht in
|
|
||||||
der Solution, baut auch im Ist-Zustand nicht (`CS5001: keine Main-Methode`). Kandidat zum
|
|
||||||
Löschen, aber nicht Teil dieser Portierung.
|
|
||||||
|
|
||||||
### AP 1.2 — Paketmatrix
|
|
||||||
|
|
||||||
| Paket | heute | Ziel | Anmerkung |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Microsoft.EntityFrameworkCore(.Relational/.Design)` | 8.0.11 | **9.0.18** | Obergrenze durch Pomelo |
|
|
||||||
| `Pomelo.EntityFrameworkCore.MySql` | 8.0.2 | **9.0.0** | zieht MySqlConnector 2.4.0 |
|
|
||||||
| `Microsoft.EntityFrameworkCore.InMemory/.Sqlite` (Tests) | 8.0.11 | **9.0.18** | muss zu EF Core passen |
|
|
||||||
| `Swashbuckle.AspNetCore` | 6.9.0 | **10.2.3** | ⚠️ 6.x ist nicht ASP.NET-Core-10-tauglich |
|
|
||||||
| `Serilog` | 4.2.0 | 4.4.0 | |
|
|
||||||
| `Serilog.AspNetCore` | 8.0.3 | 10.0.0 | |
|
|
||||||
| `Serilog.Sinks.File` | 6.0.0 | 7.0.0 | |
|
|
||||||
| `Serilog.Extensions.Logging` / `.Sinks.Console` / `.Formatting.Compact` | 8.0.0 / 6.0.0 / 3.0.0 | jeweils aktuell | |
|
|
||||||
| `Microsoft.Extensions.Http` / `.Hosting.Abstractions` / `.Logging.Abstractions` | 8.0.x | 10.0.x | |
|
|
||||||
| `Microsoft.NET.Test.Sdk` / `xunit` / `Moq` / `coverlet` | aktuell | prüfen | |
|
|
||||||
|
|
||||||
Tatsächlich aufgelöste Versionen (verifiziert über `dotnet list package`): EF Core 9.0.18,
|
|
||||||
Pomelo 9.0.0, Serilog 4.4.0 / Extensions.Logging 10.0.0 / Sinks.File 7.0.0 /
|
|
||||||
Sinks.Console 6.1.1, Swashbuckle 10.2.3, Test-SDK 18.8.1, coverlet 10.0.1.
|
|
||||||
Sicherheits-Pin: SQLitePCLRaw 2.1.12 (statt transitiv 2.1.10).
|
|
||||||
|
|
||||||
### AP 1.3 — Erwartete Bruchstellen *(Rückblick: keine eingetreten)*
|
|
||||||
* **Swashbuckle 6.9 → 10.x** ist der wahrscheinlichste Reibungspunkt: geänderte API-Oberfläche,
|
|
||||||
und ASP.NET Core 10 bringt mit `Microsoft.AspNetCore.OpenApi` eine eigene
|
|
||||||
OpenAPI-Generierung mit. Betrifft `Api/ApiConfiguration.cs:19-20` und
|
|
||||||
`WinFormsHost/Services/EmbeddedWebServer.cs:143-144,154-155`.
|
|
||||||
*Alternative:* auf das eingebaute `AddOpenApi()`/`MapOpenApi()` umsteigen und Swashbuckle
|
|
||||||
ganz ablösen — sauberer, aber die Swagger-UI müsste ersetzt werden (z. B. Scalar).
|
|
||||||
* **EF Core 8 → 9:** überschaubar. Zu prüfen sind Migrationsverhalten und die
|
|
||||||
`ExecuteSqlRawAsync`-Aufrufe in `EmbeddedWebServer.cs:56-100`.
|
|
||||||
* `Predictalytics.Application.Tests` steht bereits auf `net10.0` — hier wird die
|
|
||||||
Inkonsistenz gerade aufgelöst statt vergrößert.
|
|
||||||
|
|
||||||
**Abnahme:** `dotnet build` + `dotnet test` grün; WinFormsHost startet, Worker laufen,
|
|
||||||
Web-UI erreichbar; **Migrationen gegen die Dev-DB (`bergisnu_db0`) verifiziert, nicht gegen Prod.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Phase 2 — Hosting-Kern extrahieren ✅ **erledigt (2026-08-06)**
|
|
||||||
|
|
||||||
**Abnahme erreicht:** `dotnet build` → 0 Fehler. `dotnet test` → **100 bestanden, 0 Fehler,
|
|
||||||
1 übersprungen**. `Predictalytics.Hosting` baut ohne Windows-Bezug; `WinFormsHost` enthält
|
|
||||||
nur noch UI-Code (`MainForm`, `LicenseDialog`, `TerminalHelper`, `Program`) und referenziert
|
|
||||||
Api/Worker/Infrastructure/LicenseLabrador nur noch transitiv über Hosting.
|
|
||||||
|
|
||||||
### ⚠️ Betrieblicher Hinweis: `settings.json` ging verloren
|
|
||||||
|
|
||||||
Beim Aufräumen der Build-Ausgabe in Phase 1 wurde `bin/` gelöscht — und dort lag die zur
|
|
||||||
Laufzeit erzeugte `settings.json` mit DB-Zugangsdaten und Watchdog-API-Key. Die Datei war
|
|
||||||
nie versioniert (`.gitignore:11`) und ist nicht wiederherstellbar.
|
|
||||||
|
|
||||||
**Beim nächsten Start neu einzutragen:** DB Server/Database/User/Password, Watchdog API Key,
|
|
||||||
ggf. Egress-Kanäle.
|
|
||||||
|
|
||||||
Der Fehler kann sich nicht wiederholen: durch AP 2.3 liegen die Einstellungen jetzt unter
|
|
||||||
`%APPDATA%\Predictalytics\settings.json` bzw. `~/.config/Predictalytics/settings.json` —
|
|
||||||
außerhalb des Build-Verzeichnisses. `dotnet clean` oder ein Löschen von `bin/` sind damit
|
|
||||||
folgenlos.
|
|
||||||
|
|
||||||
### Abweichungen von der Planung
|
|
||||||
|
|
||||||
| Geplant | Tatsächlich |
|
|
||||||
|---|---|
|
|
||||||
| Hosting als `Microsoft.NET.Sdk.Web` mit `OutputType=Library` (analog Api) | **`Microsoft.NET.Sdk` + `<FrameworkReference Include="Microsoft.AspNetCore.App" />`.** Der Web-SDK globbt `wwwroot/**` automatisch als Static Web Asset und kollidierte mit dem Content-Eintrag aus AP 2.5 (`DiscoverPrecompressedAssets`: doppelter Schlüssel). Gilt auch für den Host: WinFormsHost ist jetzt ebenfalls normaler SDK. Für die Avalonia-Shell gleich so anlegen. |
|
|
||||||
| `Process.Start("explorer.exe", …)` erst in AP 3.3 ersetzen | Vorgezogen — `MainForm` wurde ohnehin umgeschrieben. Jetzt `ProcessStartInfo { UseShellExecute = true }`, funktioniert unter Windows und Linux. |
|
|
||||||
| `LicenseGuard` komplett nach Hosting | Aufgeteilt: der plattformneutrale Teil liegt in `Hosting/LicenseGuard.cs`, der interaktive Dialogaufruf in `WinFormsHost/Services/LicenseGate.cs`. Genau die Naht, an der Phase 3 den Avalonia-Dialog bzw. den Headless-Pfad einhängt. |
|
|
||||||
| `[Editor]`-Attribut für den mehrzeiligen Egress-Editor erhalten | **Entfallen.** Es verwies auf die .NET-Framework-Assembly `System.Design`, die es in .NET (Core) nicht gibt — der Editor dürfte schon vorher nicht gegriffen haben. Die Avalonia-Settings-View bekommt ein echtes mehrzeiliges Textfeld. Alle übrigen `System.ComponentModel`-Attribute sind plattformneutral und bleiben, das PropertyGrid behält Gruppen und Beschreibungen. |
|
|
||||||
|
|
||||||
### Zusätzlich mitgenommen
|
|
||||||
|
|
||||||
* `PredictalyticsOptions.WebserverHost` — Bind-Adresse konfigurierbar (Vorbereitung AP 4.1),
|
|
||||||
Default `localhost`, also verhaltensgleich.
|
|
||||||
* `PredictalyticsOptions.DbSslMode` — Default bewusst `None`, damit sich das Verbindungs-
|
|
||||||
verhalten in einem reinen Refactoring nicht ändert. Umstellung auf `Preferred` ist
|
|
||||||
jetzt eine reine Konfigurationsfrage.
|
|
||||||
* `PredictalyticsHost.GetDatabaseSizeMbAsync()` — die DB-Abfrage lag vorher direkt im
|
|
||||||
`MainForm`.
|
|
||||||
* `RichTextBoxSink` → `DelegateSink` umbenannt (war nie WinForms-abhängig).
|
|
||||||
|
|
||||||
**Reiner Verschiebe- und Entkopplungsvorgang, keine neue Fachlogik.**
|
|
||||||
|
|
||||||
### AP 2.1 — Projekt `Predictalytics.Hosting` anlegen
|
|
||||||
`net10.0`, Referenzen auf `Api`, `Worker`, `Infrastructure`, `LicenseLabrador.Client`.
|
|
||||||
|
|
||||||
### AP 2.2 — Verschieben (Tabelle in Abschnitt 1)
|
|
||||||
`EmbeddedWebServer` → `PredictalyticsHost`, Watchdog, Serilog-Setup, LicenseGuard.
|
|
||||||
`RichTextBoxSink` → `DelegateSink` umbenennen (der Sink ist ein reiner
|
|
||||||
`Action<string, LogEventLevel>`-Delegat und war nie WinForms-abhängig).
|
|
||||||
|
|
||||||
### AP 2.3 — Konfiguration entkoppeln
|
|
||||||
`AppSettings` → `PredictalyticsOptions` ohne `System.ComponentModel`-Attribute.
|
|
||||||
Ladereihenfolge: `appsettings.json` → `appsettings.{Environment}.json` →
|
|
||||||
Umgebungsvariablen (`PREDICTALYTICS_`) → `settings.json` im Benutzerprofil (Kompatibilität).
|
|
||||||
Ablageort plattformabhängig über `Environment.SpecialFolder.ApplicationData`
|
|
||||||
(Windows: `%APPDATA%`, Linux: `~/.config`) statt neben der Executable — das
|
|
||||||
Installationsverzeichnis ist unter Linux üblicherweise nicht beschreibbar.
|
|
||||||
|
|
||||||
⚠️ Beim Umbau ist `SslMode = MySqlSslMode.None` (`AppSettings.cs:100`) fest verdrahtet.
|
|
||||||
Da die Zugangsdaten künftig auch über Netz zu einer entfernten DB gehen, sollte das
|
|
||||||
konfigurierbar und standardmäßig aktiv werden.
|
|
||||||
|
|
||||||
### AP 2.4 — Lifecycle GUI-frei machen
|
|
||||||
* `System.Windows.Forms.Timer` → `PeriodicTimer` bzw. `IHostedService`
|
|
||||||
(`MainForm.cs:71` DB-Größe, `LicenseGuard.cs:70` Revalidierung)
|
|
||||||
* `Application.Exit()` → `IHostApplicationLifetime.StopApplication()`
|
|
||||||
* `PredictalyticsHost` bekommt Events/Observables (`StatusChanged`, `DbSizeChanged`),
|
|
||||||
an die sich später sowohl das Avalonia-ViewModel als auch der Headless-Modus hängen.
|
|
||||||
|
|
||||||
### AP 2.5 — wwwroot sauber deployen
|
|
||||||
`FindWwwrootPath()` (`EmbeddedWebServer.cs:308-335`) mit der Verzeichnis-Heuristik ersatzlos
|
|
||||||
streichen. Stattdessen in den Host-`.csproj`:
|
|
||||||
```xml
|
|
||||||
<Content Include="..\Predictalytics.Api\wwwroot\**"
|
|
||||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
|
||||||
CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
```
|
|
||||||
und im Code nur noch `Path.Combine(AppContext.BaseDirectory, "wwwroot")`.
|
|
||||||
|
|
||||||
**Abnahme:** WinFormsHost benutzt nur noch `Predictalytics.Hosting` und enthält
|
|
||||||
ausschließlich UI-Code. `Predictalytics.Hosting` baut ohne Windows-Referenzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Phase 3 — Avalonia-Bedienhülle ✅ **erledigt (2026-08-07)**
|
|
||||||
|
|
||||||
**Abnahme:** `dotnet build` → 0 Fehler, 0 Warnungen. `dotnet test` → 100 bestanden.
|
|
||||||
Beide Betriebsarten laufen an:
|
|
||||||
|
|
||||||
| Prüfung | Ergebnis |
|
|
||||||
|---|---|
|
|
||||||
| `Predictalytics.Shell.exe --headless` | Logging läuft, Lizenzprüfung greift, sauberer Abbruch mit **Exit-Code 2** und handlungsfähiger Meldung |
|
|
||||||
| `Predictalytics.Shell.exe` (GUI) | startet, Lizenzfenster erscheint, kein Absturz |
|
|
||||||
| Konsolen-Kodierung | UTF-8 explizit gesetzt — ohne das zeigt die Windows-Konsole nur Fragezeichen statt Rahmen und Emoji |
|
|
||||||
|
|
||||||
### Nachtrag 2026-08-07: Hauptfenster verifiziert ✅
|
|
||||||
|
|
||||||
Zunächst blockierte eine abgelaufene Lizenz die Prüfung. Nach der Umstellung auf das
|
|
||||||
Deploymentcenter (Abschnitt 5a) startet die GUI vollständig durch — belegt durch die
|
|
||||||
Logdateien des Laufs:
|
|
||||||
|
|
||||||
```
|
|
||||||
[INF] Shell initialisiert. Bereit.
|
|
||||||
[INF] 'Start Webserver' startet die WebUI auf http://localhost:5000
|
|
||||||
[INF] Einstellungen: C:\Users\bergm\AppData\Roaming\Predictalytics\settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Damit sind Lizenzschranke, Serilog-Aufbau mit Terminal-Sink, `MainWindowViewModel.Start()`
|
|
||||||
und der neue Einstellungspfad nachgewiesen. **Noch nicht durchgeklickt**: die einzelnen
|
|
||||||
Wartungsaktionen und die Einstellungsansicht — dafür fehlen die in Phase 1 verlorenen
|
|
||||||
DB-Zugangsdaten.
|
|
||||||
|
|
||||||
### Umsetzung
|
|
||||||
|
|
||||||
| Datei | Zweck |
|
|
||||||
|---|---|
|
|
||||||
| `Program.cs` | Einstiegspunkt. Argumentauswertung **vor** jeder Avalonia-Initialisierung — sonst stirbt der Prozess auf einem Server ohne X11, bevor `--headless` greift. |
|
|
||||||
| `App.axaml(.cs)` | Lizenzschranke, dann Hauptfenster. `ShutdownMode.OnExplicitShutdown` während der Aktivierung, danach `OnMainWindowClose`. |
|
|
||||||
| `ViewModels/MainWindowViewModel.cs` | Bindungsziel, `CommunityToolkit.Mvvm`-Quellgeneratoren, kapselt `PredictalyticsHost` |
|
|
||||||
| `ViewModels/LogLine.cs` | Terminalzeile mit Einfärbung nach Loglevel (Farbschema aus WinForms übernommen) |
|
|
||||||
| `Views/MainWindow.axaml` | Menü, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab, Statusleiste |
|
|
||||||
| `Views/LicenseWindow.axaml` | Aktivierungsfenster, ersetzt `LicenseDialog` |
|
|
||||||
| `Views/Dialogs.cs` | Ersatz für `MessageBox.Show` — im Code aufgebaut, kein Drittanbieterpaket |
|
|
||||||
| `Services/HeadlessRunner.cs` | AP 3.4: Betrieb ohne Oberfläche |
|
|
||||||
| `Services/ConsoleAttach.cs` | Windows-Konsole anhängen + UTF-8 |
|
|
||||||
|
|
||||||
### Abweichungen und Entscheidungen
|
|
||||||
|
|
||||||
* **`PropertyGrid`-Ersatz handgeschrieben**, wie empfohlen. Vier Gruppen als
|
|
||||||
`HeaderedContentControl`, Passwortfelder mit `PasswordChar`, **echtes mehrzeiliges
|
|
||||||
Textfeld** für die Egress-Kanäle (besser als das in Phase 2 entfallene `[Editor]`-Attribut).
|
|
||||||
SSL-Modus als `ComboBox`; bewusst nur `None`/`Preferred`/`Required` — `VerifyCA`/`VerifyFull`
|
|
||||||
bräuchten hinterlegte Zertifikate und würden hier nur zu Fehlkonfiguration einladen.
|
|
||||||
* **Speichern ist jetzt explizit** („Speichern & übernehmen") statt wie beim `PropertyGrid`
|
|
||||||
bei jeder Einzeländerung. Klarer, und erspart `INotifyPropertyChanged` auf `PredictalyticsOptions`.
|
|
||||||
* **Feste Fenstergröße aufgelöst** (vorher `1886×1088` fix): jetzt `MinWidth`/`MinHeight`
|
|
||||||
mit skalierendem `DockPanel`/`Grid`.
|
|
||||||
* **Schrift-Fallbackkette** `Cascadia Code, DejaVu Sans Mono, Liberation Mono, Consolas, monospace`
|
|
||||||
fürs Terminal; `Avalonia.Fonts.Inter` als mitgelieferte UI-Schrift, weil Linux-Distributionen
|
|
||||||
sehr unterschiedliche Standardfonts haben.
|
|
||||||
* **Ringpuffer statt Leeren**: die WinForms-Fassung verwarf bei 500 Zeilen das gesamte
|
|
||||||
Terminal (`rtb.Clear()`), jetzt fällt jeweils nur die älteste Zeile heraus.
|
|
||||||
* **`Avalonia.Diagnostics` nicht referenziert** — gibt es nur bis 11.3.18, nicht für 12.x.
|
|
||||||
* **`global::Avalonia.Application`**: innerhalb von `Predictalytics.*` löst der kurze Name
|
|
||||||
`Application` auf den eigenen Namespace `Predictalytics.Application` auf. Betrifft jede
|
|
||||||
künftige Avalonia-Klasse in diesem Projekt.
|
|
||||||
* **`Microsoft.NET.Sdk` statt `Sdk.Web`**, wie in Phase 2 gelernt (Static-Web-Assets-Kollision
|
|
||||||
mit dem `wwwroot`-Content-Eintrag).
|
|
||||||
|
|
||||||
### Headless-Betrieb
|
|
||||||
|
|
||||||
Lizenz über `PREDICTALYTICS_LICENSE_KEY`; ohne nutzbare Lizenz Abbruch mit Exit-Code 2
|
|
||||||
statt eines Dialogs, den niemand sieht. `SIGTERM` und `SIGINT` werden über
|
|
||||||
`PosixSignalRegistration` abgefangen, damit systemd sauber stoppen kann.
|
|
||||||
|
|
||||||
Exit-Codes: `0` planmäßig beendet · `2` keine nutzbare Lizenz · `3` Abbruch mit Fehler.
|
|
||||||
|
|
||||||
### AP 3.1 — Projekt `Predictalytics.Shell`
|
|
||||||
```xml
|
|
||||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
|
||||||
```
|
|
||||||
MVVM mit `CommunityToolkit.Mvvm` (Source-Generatoren) — bei ~15 Settings-Properties plus
|
|
||||||
Status-Bindings spart das spürbar Boilerplate gegenüber handgeschriebenem `INotifyPropertyChanged`.
|
|
||||||
|
|
||||||
### AP 3.2 — Control-Mapping
|
|
||||||
|
|
||||||
| WinForms (heute) | Avalonia | Aufwand |
|
|
||||||
|---|---|---|
|
|
||||||
| `MenuStrip` + 3 Menüs | `Menu` / `NativeMenuBar` | klein |
|
|
||||||
| `ToolStrip` + 2 Buttons | `StackPanel` + `Button` | klein |
|
|
||||||
| `TabControl` / `TabPage` | `TabControl` / `TabItem` | klein |
|
|
||||||
| `StatusStrip` + 3 Labels | `DockPanel` unten + `TextBlock` | klein |
|
|
||||||
| `MessageBox.Show` (7 Aufrufe) | eigener Dialog oder `MsBox.Avalonia` | klein |
|
|
||||||
| **`RichTextBox` (farbiges Log)** | `ListBox`/`ItemsControl` über `ObservableCollection<LogLine>`, `DataTemplate` mit `Foreground`-Binding | **mittel** |
|
|
||||||
| **`PropertyGrid`** | **kein Äquivalent in Avalonia Core** | **mittel–groß** |
|
|
||||||
|
|
||||||
**Zum Log-View:** die bisherige Ringpuffer-Logik (`TerminalHelper.cs:26-31` — bei 500 Zeilen
|
|
||||||
`rtb.Clear()`) wird zur `ObservableCollection` mit `RemoveAt(0)` — angenehmer, weil die
|
|
||||||
Historie nicht mehr komplett verworfen wird. Marshalling über `Dispatcher.UIThread.Post`
|
|
||||||
statt `Control.BeginInvoke`. Farbschema aus `TerminalHelper.cs:33-39` direkt übernehmbar.
|
|
||||||
|
|
||||||
**Zum PropertyGrid** — das ist der einzige echte UI-Entwurfsaufwand. Drei Optionen:
|
|
||||||
|
|
||||||
| Option | Bewertung |
|
|
||||||
|---|---|
|
|
||||||
| **Handgeschriebene Settings-View** (empfohlen) | 4 Gruppen / 15 Properties (Webserver 2, Egress 1, Watchdog 6, Database 4 + abgeleiteter ConnectionString). Als `Expander` je Gruppe mit `TextBox`/`CheckBox`/`NumericUpDown`. Volle Kontrolle über Passwortfelder (`PasswordChar`) und Mehrzeilen-Editor für Egress. ~1–1,5 PT. |
|
|
||||||
| Drittanbieter `Avalonia.PropertyGrid` | Spart Zeit, bringt aber eine Abhängigkeit fragwürdiger Pflegelage und passt selten zum Fluent-Theme. |
|
|
||||||
| Reflection-basierter Generator | Elegant, aber Overengineering für 15 Properties. |
|
|
||||||
|
|
||||||
### AP 3.3 — Plattformdetails
|
|
||||||
* **Fixe Fenstergröße auflösen:** `MinimumSize`/`MaximumSize = 1886×1088`
|
|
||||||
(`MainForm.Designer.cs:233-234`) ist bei abweichender DPI/Auflösung unter Linux
|
|
||||||
untauglich. → skalierendes Grid-Layout, sinnvolles `MinWidth`/`MinHeight`.
|
|
||||||
* **Schriftart:** `"Cascadia Code"` (`MainForm.cs:25`) existiert unter Linux meist nicht.
|
|
||||||
→ Fallback-Kette `Cascadia Code, DejaVu Sans Mono, monospace`.
|
|
||||||
* **`Process.Start("explorer.exe", …)`** (3×, `MainForm.cs:192,207,209`) →
|
|
||||||
```csharp
|
|
||||||
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
|
|
||||||
```
|
|
||||||
funktioniert auf Windows und Linux (dort über `xdg-open`).
|
|
||||||
* **Lizenzdialog** (`LicenseDialog.cs`, 118 Z.) in Avalonia nachbauen: Label, `TextBox`
|
|
||||||
(Großschreibung — `CharacterCasing.Upper` hat kein direktes Pendant, per
|
|
||||||
`TextInput`-Handler oder Converter lösen), Status-Label, zwei Buttons. Der
|
|
||||||
Aktivierungsablauf (`ValidateAsync` + `VerifyChecksum`) bleibt unverändert.
|
|
||||||
*Hinweis: greift auf die HardwareID-Arbeit zu, die separat läuft.*
|
|
||||||
|
|
||||||
### AP 3.4 — Headless-Modus *(verbindlich, 0,5 PT — Entscheidung 0.3)*
|
|
||||||
Schalter `--headless` in `Program.cs` der Shell: statt `BuildAvaloniaApp().StartWithClassicDesktopLifetime()`
|
|
||||||
direkt `PredictalyticsHost` starten und auf `IHostApplicationLifetime` warten, Logs nur auf
|
|
||||||
Console. Nach Phase 2 ist das reine Verdrahtung, weil der Kern bereits GUI-frei ist.
|
|
||||||
|
|
||||||
Zwingend mitzudenken:
|
|
||||||
* **Lizenz ohne Dialog:** Schlüssel aus `PREDICTALYTICS_LICENSE_KEY` bzw. Konfiguration;
|
|
||||||
bei fehlender/ungültiger Lizenz sauberer Abbruch mit Exit-Code ≠ 0 und klarer Logzeile
|
|
||||||
statt eines Dialogs, den niemand sieht.
|
|
||||||
* **Keine Avalonia-Initialisierung** vor der Argumentauswertung — sonst versucht der
|
|
||||||
Prozess auf einem Server ohne X11 zu starten und stirbt, bevor der Schalter greift.
|
|
||||||
* SIGTERM sauber behandeln (`IHostApplicationLifetime`), damit systemd korrekt stoppt.
|
|
||||||
|
|
||||||
**Abnahme:** Shell startet unter Windows **und** Linux-Desktop mit GUI; derselbe Build
|
|
||||||
startet mit `--headless` auf einem Server ohne X11. In beiden Modi: Worker laufen, Web-UI
|
|
||||||
erreichbar, Settings persistieren; im GUI-Modus zusätzlich farbiger Log-View und
|
|
||||||
Lizenzdialog.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5b. WinForms-Host abgelöst ✅ (2026-08-08)
|
|
||||||
|
|
||||||
`main` brachte eine vollständige Deployment-Center-Integration mit (Lizenz, Heartbeat mit
|
|
||||||
DB-Health-Check, Fehler-Stream, UpdateService), die im WinForms-Host entstanden war. Diese
|
|
||||||
wurde nach `feature/linux-net10` gemerged und dabei in den plattformneutralen Kern gezogen.
|
|
||||||
**`Predictalytics.WinFormsHost` ist entfernt** — die Avalonia-Shell ist ab jetzt die einzige
|
|
||||||
Bedienoberfläche.
|
|
||||||
|
|
||||||
### Was nach `Predictalytics.Hosting` wanderte
|
|
||||||
|
|
||||||
| Datei | Anmerkung |
|
|
||||||
|---|---|
|
|
||||||
| `DcConfig`, `DcApiClient`, `DcErrorSink`, `DcHeartbeatService`, `DcUpdateService` | unverändert, waren bereits plattformneutral |
|
|
||||||
| `DcErrorReporter` | `Application.ThreadException` + `MessageBox` entfernt; stattdessen `ReportUiThreadException(Exception)`, das der Host aufruft. Avalonia hängt sich über `Dispatcher.UIThread.UnhandledException` ein. |
|
|
||||||
| `LicenseGuard` + `LicenseSession` | Dialogaufruf und `System.Windows.Forms.Timer` entfernt. Neu: `TryUseCachedAsync()`, `ActivateAsync()` und `StartPeriodicRevalidation(session, onUnusable)` über `PeriodicTimer`. Die Unterscheidung transienter Fehler und die Gnadenfrist-Warnung sind unverändert übernommen. |
|
|
||||||
| Dc-Einstellungen | aus `AppSettings` nach `PredictalyticsOptions`; die Watchdog-Einstellungen entfielen |
|
|
||||||
| `DcErrorSink` in Serilog | in `LoggingSetup` aufgenommen, Startbanner nutzt jetzt `DcConfig.AppVersion` |
|
|
||||||
|
|
||||||
`Deploymentcenter.BuildInfo.targets` wird nun von `Predictalytics.Hosting` importiert;
|
|
||||||
`BuildInfo` entsteht damit im Namensraum `Predictalytics.Hosting`.
|
|
||||||
|
|
||||||
### Was in der Avalonia-Shell nachgezogen wurde
|
|
||||||
|
|
||||||
* Menü **Deployment Center** mit „Nach Updates suchen" und „Lizenzstatus anzeigen"
|
|
||||||
* Einstellungsgruppe **Watchdog → Deployment Center** (Token, Heartbeat, Fehler-Reporting,
|
|
||||||
Update-Kanal als ComboBox; die Server-URL wird nur angezeigt, nicht editiert)
|
|
||||||
* Heartbeat-Snapshot samt `SELECT 1`-DB-Probe, identisch zur WinForms-Fassung
|
|
||||||
* Update-Prüfung beim Start (still) und über das Menü (interaktiv), inklusive
|
|
||||||
`NotifyStopping()` vor dem Start des Update-Agenten
|
|
||||||
* Lizenzfenster nutzt `IsTransient`: bei fehlender Serververbindung wird nicht behauptet,
|
|
||||||
die Lizenz sei ungültig
|
|
||||||
|
|
||||||
### Verifiziert
|
|
||||||
|
|
||||||
```
|
|
||||||
🔑 Lizenz geprüft: valid (HWID 2:win:a765bd47…, Quelle machine-guid)
|
|
||||||
Shell initialisiert. Bereit.
|
|
||||||
🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token
|
|
||||||
Update-Prüfung: v1.0.0 ist aktuell (Kanal prod)
|
|
||||||
```
|
|
||||||
|
|
||||||
`--license-status` meldet gültig samt Offline-Gnadenfrist. Build 0 Fehler, 100 Tests grün.
|
|
||||||
**Noch offen:** Heartbeat und Fehler-Stream laufen erst mit gesetztem DC-Token; die
|
|
||||||
Wartungsaktionen brauchen DB-Zugangsdaten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5a. Zusatz — Umstellung auf das Deploymentcenter-Lizenzsystem ✅ (2026-08-07)
|
|
||||||
|
|
||||||
> **Überholt durch 5b.** Der hier beschriebene eigene `LicenseGuard` gegen
|
|
||||||
> `Deploymentcenter.Client` war ein Provisorium und wurde durch die reifere Fassung aus
|
|
||||||
> `main` ersetzt (`LicenseSession`, `IsTransient`, `TryGetCachedKey`, Gnadenfrist-Warnung).
|
|
||||||
> Die Begründungen zur Hardware-ID v2 und zum AES-GCM-Cache gelten unverändert.
|
|
||||||
|
|
||||||
**Nicht ursprünglich geplant.** Beim Aktivieren der neuen Lizenz stellte sich heraus, dass
|
|
||||||
Predictalytics noch am alten Lizenzsystem hing (LicenseLabrador, `license.mhdf.de`),
|
|
||||||
während die Lizenzen inzwischen im Deploymentcenter (`dc.mhdf.de`) gepflegt werden.
|
|
||||||
|
|
||||||
Vor dem Umbau mit einem Wegwerf-Probelauf gegen den echten Client abgesichert:
|
|
||||||
Slug `predictalytics`, HWID v2 aus `machine-guid`, Server meldet `valid`.
|
|
||||||
|
|
||||||
### Was sich geändert hat
|
|
||||||
|
|
||||||
| | vorher (LicenseLabrador) | jetzt (Deploymentcenter) |
|
|
||||||
|---|---|---|
|
|
||||||
| Endpunkt | `license.mhdf.de/public/api/v1` | `dc.mhdf.de/api/license/v1` |
|
|
||||||
| Client | `LicenseLabrador.Client` (netstandard2.0) | `Deploymentcenter.Client` (netstandard2.0;net8.0) |
|
|
||||||
| Authentifizierung | Ed25519-Signaturprüfung + Basic Auth im Quelltext | serverseitig, kein Geheimnis im Client |
|
|
||||||
| Hardware-ID | SHA-256 aus MachineGuid **+ Rechnername** | **v2**: `2:<plattform>:<hex>`, ohne Rechnername |
|
|
||||||
| Linux-HWID | `/etc/machine-id`, sonst MAC | `/etc/machine-id` → dbus → DMI-UUID → MAC → Schlüsseldatei |
|
|
||||||
| Container | nicht vorgesehen | `DEPLOYMENTCENTER_HWID` überschreibt |
|
|
||||||
| Cache | DPAPI, **stiller Klartext-Rückfall unter Linux** | AES-256-GCM, HKDF aus HWID+Slug, **kein Klartext-Rückfall**, `0600` unter Linux |
|
|
||||||
| Ablageort | `%APPDATA%` | zusätzlich `XDG_CONFIG_HOME` unter Linux |
|
|
||||||
|
|
||||||
**Damit erledigt sich Analyse-Punkt 3.2 Nr. 2 von selbst:** der stille Klartext-Rückfall
|
|
||||||
des Lizenz-Caches unter Linux existiert im neuen Client nicht mehr. Der Rechnername steckt
|
|
||||||
außerdem nicht mehr im Hash — Umbenennen verbraucht keinen Aktivierungsplatz.
|
|
||||||
|
|
||||||
### Neue CLI-Schalter (`Services/LicenseCli.cs`)
|
|
||||||
|
|
||||||
Aus dem Deploymentcenter-Integrationsleitfaden übernommen, für Server ohne Oberfläche:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
Predictalytics.Shell --license-status # HWID, Cache-Ablage, Serverprüfung
|
|
||||||
Predictalytics.Shell --license-set-key <SCHLÜSSEL> # einmalig aktivieren
|
|
||||||
Predictalytics.Shell --license-deactivate # Aktivierungsplatz freigeben
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Schalter werden **vor** jeder Avalonia-Initialisierung ausgewertet, wie `--headless`.
|
|
||||||
Der Schlüssel wird in der Ausgabe maskiert (`LLAB3-*****-*****-*****-AABB2`).
|
|
||||||
|
|
||||||
### ✅ Nachgetragen: Watchdog ist migriert (Stand 2026-08-23)
|
|
||||||
|
|
||||||
Der hier notierte Rest — `WatchdogHeartbeatService` gegen `watchdog.mhdf.de` mit dem
|
|
||||||
Header `X-Watchdog-Key` — ist erledigt. An seiner Stelle steht
|
|
||||||
`Hosting/DcHeartbeatService.cs`, das gegen `POST /api/watchdog/v1/ping` am
|
|
||||||
Deployment Center meldet (`:105,143`). Damit laufen Lizenz, Watchdog, UpdateService,
|
|
||||||
Fehler-Stream und Bugtracker über dieselbe Anbindung; `watchdog.mhdf.de` wird
|
|
||||||
nicht mehr angesprochen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Phase 4 — Aufräumen und Absichern ✅ **erledigt (2026-08-08)**
|
|
||||||
|
|
||||||
**Abnahme:** Build 0 Fehler, 8 Warnungen (alle vorbestehend).
|
|
||||||
`dotnet test` → **124 bestanden** (vorher 100; 24 neue Tests zur Absicherung).
|
|
||||||
|
|
||||||
### AP 4.1 — Steuerendpunkte werden jetzt tatsächlich geschützt ✅
|
|
||||||
|
|
||||||
Der Befund war schlimmer als in der Analyse notiert: `/api/capabilities` **meldete**
|
|
||||||
`CanControl`/`AuthRequired` aus der Konfiguration, **erzwungen wurde nichts**.
|
|
||||||
`MapPredictalyticsControlEndpoints()` mappte Jobs, Trader-Steuerung und die Dev-Endpunkte
|
|
||||||
bedingungslos und ungeschützt.
|
|
||||||
|
|
||||||
Neu:
|
|
||||||
|
|
||||||
| Bindung | Token | Verhalten |
|
|
||||||
|---|---|---|
|
|
||||||
| localhost | — | Steuerendpunkte aktiv, ungeschützt (das Betriebssystem schützt) |
|
|
||||||
| localhost | gesetzt | Steuerendpunkte aktiv, Token wird verlangt |
|
|
||||||
| **extern** | **fehlt** | **Steuerendpunkte werden gar nicht gemappt**, dazu ein `Log.Error` |
|
|
||||||
| extern | gesetzt | Steuerendpunkte verlangen `X-Predictalytics-Key` |
|
|
||||||
|
|
||||||
Bewusst fail-safe: eine unbedachte Umstellung der Bind-Adresse öffnet nichts stillschweigend.
|
|
||||||
`ApiTokenFilter` vergleicht laufzeitkonstant (`CryptographicOperations.FixedTimeEquals`),
|
|
||||||
damit die Antwortzeit nicht verrät, wie viele Zeichen stimmen.
|
|
||||||
Swagger ist bei externer Bindung abgeschaltet — es legt die gesamte API-Oberfläche offen.
|
|
||||||
`/api/capabilities` meldet nun den tatsächlichen Zustand statt einer Konfigurationsabsicht.
|
|
||||||
|
|
||||||
### AP 4.2 — Globalisierung ✅
|
|
||||||
`RuntimeSetup.UseInvariantCulture()`, aufgerufen als Erstes in beiden Startpfaden.
|
|
||||||
Bewusst **nicht** über `InvariantGlobalization` im Projekt: das änderte zusätzlich die
|
|
||||||
Semantik von Zeichenkettenvergleichen und wäre ein größerer Eingriff als nötig.
|
|
||||||
|
|
||||||
### AP 4.3 — Log-Verzeichnis ✅
|
|
||||||
Standard ist jetzt `%APPDATA%\Predictalytics\logs` bzw. `~/.config/Predictalytics/logs` —
|
|
||||||
neben den Einstellungen statt neben der Programmdatei, die unter Linux üblicherweise nicht
|
|
||||||
beschreibbar ist und beim Update überschrieben würde. Übersteuerbar über
|
|
||||||
`PREDICTALYTICS_LOG_DIR`, etwa auf `/var/log/predictalytics` im systemd-Betrieb.
|
|
||||||
|
|
||||||
### AP 4.4 — Solution und Repo-Kopplung ✅
|
|
||||||
Der WinFormsHost-Sonderfall entfällt, weil das Projekt entfernt ist — die Solution baut
|
|
||||||
unter Linux ohne Bedingung durch. Die relative Referenz auf das Schwester-Repo bleibt
|
|
||||||
(vier Ebenen aufwärts), scheitert jetzt aber mit einer verständlichen Meldung samt
|
|
||||||
Klon-URL statt mit einem Fehler über eine fehlende Projektdatei.
|
|
||||||
|
|
||||||
**Offen:** Git-Submodul oder NuGet-Paket statt Pfadreferenz — für CI und Container relevant,
|
|
||||||
aber eine Entscheidung über Repo-Grenzen hinweg.
|
|
||||||
|
|
||||||
### AP 4.5 — Egress `SourceIp` ✅ (dokumentiert)
|
|
||||||
Code ist korrekt; der Unterschied ist zur Laufzeit. Linux verlangt, dass die Adresse
|
|
||||||
tatsächlich auf einem Interface konfiguriert ist (sonst `EADDRNOTAVAIL`), Windows ist
|
|
||||||
toleranter. Als Kommentar an der Bind-Stelle hinterlegt. Praktischer Test steht in Phase 6 aus.
|
|
||||||
|
|
||||||
### Neue Tests
|
|
||||||
|
|
||||||
* `PredictalyticsOptionsTests` — `IsPubliclyBound` für Loopback-Schreibweisen, Leerwerte
|
|
||||||
und externe Adressen; `WebserverUrl`; SSL-Modus im Connection-String; und dass leere
|
|
||||||
Eingaben bestehende DB-Zugangsdaten **nicht** überschreiben.
|
|
||||||
* `ApiTokenFilterTests` — richtiger Token passiert, fehlender/falscher/zu kurzer/zu langer
|
|
||||||
sowie abweichend geschriebener Token werden abgewiesen.
|
|
||||||
|
|
||||||
### ✅ Ende-zu-Ende verifiziert (2026-08-08, gegen die echte Datenbank)
|
|
||||||
|
|
||||||
Nachgeholt, sobald die DB-Zugangsdaten wieder vorlagen. Geprüft wurde mit einem
|
|
||||||
Wegwerf-Host, der **ausschließlich den Webserver** startet — die Worker blieben aus, weil
|
|
||||||
parallel eine ältere Instanz auf derselben Datenbank läuft. Zusätzlich mit
|
|
||||||
`ApiSettings__ReadOnlyDatabase=true`, sodass weder Migrationen noch Seeding liefen
|
|
||||||
(im Log bestätigt).
|
|
||||||
|
|
||||||
Als „externe" Bindung diente `127.0.0.2`: die Adresse gilt der Prüfung als öffentlich,
|
|
||||||
bleibt aber Loopback — der Test öffnet also nichts im Netz.
|
|
||||||
|
|
||||||
| Fall | `/api/capabilities` | `/api/jobs` | Swagger |
|
|
||||||
|---|---|---|---|
|
|
||||||
| localhost, kein Token | `canControl:true, authRequired:false` | 200 | 200 |
|
|
||||||
| **extern, kein Token** | `canControl:false` | **404** | **404** |
|
|
||||||
| extern, mit Token | `canControl:true, authRequired:true, tokenHeader:"X-Predictalytics-Key"` | ohne Header **401**, falscher Token **401**, richtiger **200** | **404** |
|
|
||||||
|
|
||||||
Damit ist belegt: eine externe Bindung ohne Token stellt die Steuerendpunkte gar nicht
|
|
||||||
erst bereit, und Swagger ist in beiden externen Fällen abgeschaltet.
|
|
||||||
|
|
||||||
**Gleichzeitig verifiziert** (Vorgriff auf Phase 6):
|
|
||||||
|
|
||||||
* Datenbankverbindung steht — 37.803 MB, `SELECT`-Pfad über `GetDatabaseSizeMbAsync`
|
|
||||||
* Web-UI und Assets werden ausgeliefert: `/`, `/css/style.css`, `/js/app.js` je 200
|
|
||||||
* `/api/dashboard` liefert echte Daten: 151.094 Trader, 38.187.946 Trades
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6a. Phase 4 — ursprüngliche Planung
|
|
||||||
|
|
||||||
### AP 4.1 — Kestrel-Binding und Absicherung ⚠️
|
|
||||||
`UseUrls($"http://localhost:{port}")` (`EmbeddedWebServer.cs:122`) konfigurierbar machen
|
|
||||||
(`ASPNETCORE_URLS` bzw. Options), CORS-`AllowedOrigins` (heute fest `http://localhost:5000`)
|
|
||||||
mitziehen.
|
|
||||||
|
|
||||||
**Sobald nicht mehr nur auf `localhost` gelauscht wird, entfällt die bisher implizite
|
|
||||||
Absicherung.** `MapPredictalyticsControlEndpoints()` (schreibende Operationen) und Swagger
|
|
||||||
wären dann offen erreichbar. Das vorhandene `CanControl`/`AuthRequired`-Konzept
|
|
||||||
(`Api/ApiConfiguration.cs:66`) muss davor scharf geschaltet werden.
|
|
||||||
|
|
||||||
### AP 4.2 — Globalisierung
|
|
||||||
Im Host-Bootstrap `CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture`
|
|
||||||
setzen, damit Zahlenformatierung unabhängig von der Systemlocale ist.
|
|
||||||
`InvariantGlobalization=true` (spart `libicu` im Container) erst später erwägen — es ändert
|
|
||||||
String-Vergleichssemantik und sollte nicht im selben Sprung passieren.
|
|
||||||
|
|
||||||
### AP 4.3 — Log-Verzeichnis
|
|
||||||
`Path.Combine(AppContext.BaseDirectory, "logs")` (`Program.cs:33`) konfigurierbar machen.
|
|
||||||
Default: Windows `%APPDATA%`, Linux `~/.local/share` bzw. `/var/log/predictalytics`.
|
|
||||||
|
|
||||||
### AP 4.4 — Solution und Repo-Kopplung
|
|
||||||
* `Predictalytics.slnx`: WinFormsHost mit
|
|
||||||
`Condition="$([MSBuild]::IsOSPlatform('Windows'))"` versehen, damit
|
|
||||||
`dotnet build` der Gesamtsolution unter Linux durchläuft.
|
|
||||||
* Die `..\..\..\..\LicenseLabrador\…`-Projektreferenz (4 Ebenen aufwärts) ist für CI und
|
|
||||||
Container fragil → Git-Submodul oder NuGet-Paket.
|
|
||||||
|
|
||||||
### AP 4.5 — Egress-Kanäle prüfen ⚠️
|
|
||||||
`EgressPoolService.cs:70-88` bindet Sockets an eine Quell-IP. Unter Linux muss die IP
|
|
||||||
tatsächlich auf einem Interface konfiguriert sein (sonst `EADDRNOTAVAIL`); Windows ist
|
|
||||||
toleranter. Für nicht-lokale IPs bräuchte es `IP_FREEBIND`/`CAP_NET_ADMIN`.
|
|
||||||
Nur relevant, falls Egress-Kanäle produktiv genutzt werden. Proxy-Kanäle sind unkritisch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Phase 5 — Deployment und CI (1–2 PT)
|
|
||||||
|
|
||||||
* `dotnet publish -r linux-x64` (und `win-x64`); bei self-contained an
|
|
||||||
`--self-contained` + Trimming-Verhalten von Avalonia denken.
|
|
||||||
* **Systemabhängigkeiten Linux (GUI):** `libx11-6`, `libice6`, `libsm6`, `libfontconfig1`,
|
|
||||||
`libharfbuzz0b`, Monospace-Fonts (z. B. `fonts-dejavu`). **(GUI-Variante braucht eine
|
|
||||||
Desktop-Session — siehe 0.3.)**
|
|
||||||
* systemd-Unit bzw. Dockerfile (für den Headless-Modus); es existiert bereits ein
|
|
||||||
`J:\Softwareprojekte\dockerCompose` — vermutlich anknüpfbar.
|
|
||||||
* Reverse Proxy (nginx/Caddy) mit TLS vor Kestrel.
|
|
||||||
* **Secrets:** DB-Passwort und Deployment-Center-Token liegen im Klartext in
|
|
||||||
`settings.json` (nicht versioniert). Unter Linux → EnvironmentFile mit `0600` oder
|
|
||||||
Secret-Store.
|
|
||||||
*Erledigt:* Die früher hier genannten Basic-Auth-Zugangsdaten in `LicenseGuard.cs`
|
|
||||||
gibt es nicht mehr — die Datei läuft vollständig über den `Deploymentcenter.Client`.
|
|
||||||
*Offen und dringlicher:* In `src/Predictalytics.Api/appsettings.json` steht ein
|
|
||||||
**echter OpenRouter-API-Key im Klartext**, und diese Datei **ist** versioniert.
|
|
||||||
Schlüssel zurückziehen, neu ausstellen, künftig über `OpenRouter__ApiKey` setzen.
|
|
||||||
* CI: Build + Test auf Linux **und** Windows.
|
|
||||||
* `docs/BETRIEB-Deploymentcenter.md` um den Linux-Betrieb ergänzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Phase 6 — Verifikation (2 PT)
|
|
||||||
|
|
||||||
| # | Prüfung |
|
|
||||||
|---|---|
|
|
||||||
| 8.1 | Worker-Dauerlauf ≥ 24 h unter Linux gegen die **Dev-DB**, Logvergleich mit einem Windows-Lauf |
|
|
||||||
| 8.2 | EF-Migrationen von Null gegen Dev-DB (Weg A: nur EF-9-Regression) |
|
|
||||||
| 8.3 | Zahlenformatierung: Lauf unter `LANG=de_DE.UTF-8` — Volumina/Preise/PnL gegen Windows-Referenz |
|
|
||||||
| 8.4 | Web-UI vollständig durchklicken (case-sensitive Assets, alle Endpunkte) |
|
|
||||||
| 8.5 | Lizenz: Erstaktivierung, Neustart aus Cache, Offline-Gnadenfrist, Revalidierung |
|
|
||||||
| 8.6 | Watchdog-Heartbeats erscheinen unter `watchdog.mhdf.de` |
|
|
||||||
| 8.7 | Egress-Kanäle (falls genutzt) |
|
|
||||||
| 8.8 | Shell unter Windows **und** Linux-Desktop: alle Buttons, Menüs, Settings-Persistenz |
|
|
||||||
| 8.9 | Derselbe Build mit `--headless` auf einem Server ohne X11: Start, Lizenz aus Konfiguration, SIGTERM-Stopp |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Zeitplan
|
|
||||||
|
|
||||||
| Phase | Inhalt | PT |
|
|
||||||
|---|---|---|
|
|
||||||
| 0 | Vorarbeiten | 0,5 |
|
|
||||||
| 1 | .NET 10 + Paketmatrix | 1,5–2,5 |
|
|
||||||
| 2 | Hosting-Kern extrahieren | 1,5–2 |
|
|
||||||
| 3 | Avalonia-Shell **inkl. Headless-Modus (AP 3.4)** | 4,5–6,5 |
|
|
||||||
| 4 | Aufräumen, Binding, Absicherung | 1–1,5 |
|
|
||||||
| 5 | Deployment + CI (Desktop **und** systemd/Container) | 1,5–2,5 |
|
|
||||||
| 6 | Verifikation (beide Betriebsarten) | 2 |
|
|
||||||
| | **Summe** | **12,5–17,5 PT** |
|
|
||||||
|
|
||||||
**≈ 2,5–3,5 Wochen fokussiert.**
|
|
||||||
|
|
||||||
Phasen 0–2 sind risikoarm und liefern bereits Wert (aufgeräumte Architektur, aktuelles
|
|
||||||
Framework). Phase 3 ist der Hauptaufwand. Die Phasen sind strikt sequenziell — jede baut
|
|
||||||
auf der vorigen auf; einzig AP 5 (Deployment) kann parallel zu Phase 3 vorbereitet werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Risiken
|
|
||||||
|
|
||||||
| Risiko | Eintritt | Wirkung | Gegenmaßnahme |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Pomelo hat keinen EF-Core-10-Provider** | **sicher** | blockiert EF Core 10 | ✅ gelöst durch Weg A: EF Core 9 auf net10.0 |
|
|
||||||
| Swashbuckle 6.9 → 10.x mit Brüchen | wahrscheinlich | halber bis ganzer Tag | ggf. auf eingebautes `AddOpenApi()` wechseln |
|
|
||||||
| PropertyGrid-Ersatz wird aufwendiger als geschätzt | mittel | +1 PT | handgeschriebene View, 15 Properties sind überschaubar |
|
|
||||||
| Avalonia-GUI läuft auf dem Linux-Ziel nicht (kein Desktop) | sicher auf Servern | Shell unbrauchbar | ✅ abgedeckt durch AP 3.4 (verbindlich) |
|
|
||||||
| `Avalonia.Diagnostics` fehlt für 12.x | sicher | nur Komfort | ohne DevTools arbeiten oder 11.3.18 in Debug |
|
|
||||||
| **Lizenzaktivierung headless** | **sicher (AP 3.4)** | mittel | koppelt an laufende HardwareID-Arbeit — früh abstimmen |
|
|
||||||
| Egress `SourceIp` unter Linux | niedrig | mittel | AP 4.5, früh testen |
|
|
||||||
| Regression in der Datenschicht | niedrig (Weg A) | hoch | ausschließlich Dev-DB, nie Prod |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Nächster Schritt
|
|
||||||
|
|
||||||
**Stand 2026-08-23: Phase 0 bis 4 sind abgeschlossen** (jeweils oben mit ✅ vermerkt),
|
|
||||||
ebenso der Zusatz 5a (Deploymentcenter-Lizenzsystem) und 5b (WinForms-Host abgelöst).
|
|
||||||
Der ursprüngliche Text an dieser Stelle empfahl noch, mit Phase 0 zu beginnen — das ist
|
|
||||||
überholt und deshalb ersetzt.
|
|
||||||
|
|
||||||
**Offen sind Phase 5 und Phase 6:**
|
|
||||||
|
|
||||||
1. **Phase 5 — Deployment und CI** (Abschnitt 7). Nichts davon ist bisher angefasst: kein
|
|
||||||
`dotnet publish -r linux-x64`, keine systemd-Unit, kein Dockerfile, kein Reverse Proxy,
|
|
||||||
keine CI. Das ist der eigentliche verbleibende Arbeitsblock.
|
|
||||||
2. **Phase 6 — Verifikation** (Abschnitt 8, Punkte 8.1–8.9). Der Dauerlauf unter Linux
|
|
||||||
(8.1) und die Prüfung unter `LANG=de_DE.UTF-8` (8.3) stehen aus. **Bisher wurde die
|
|
||||||
Anwendung noch auf keinem Linux-System ausgeführt** — die Portierung ist zwar
|
|
||||||
vollständig gebaut, aber unter Linux unbewiesen.
|
|
||||||
3. Unabhängig davon und vorgelagert: den **OpenRouter-API-Key** aus
|
|
||||||
`src/Predictalytics.Api/appsettings.json` zurückziehen und neu ausstellen (siehe
|
|
||||||
Abschnitt 7, Secrets).
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Archiv — abgelöste Plandokumente
|
|
||||||
|
|
||||||
Diese Dokumente wurden am **2026-08-23** hierher verschoben. Sie werden **nicht mehr
|
|
||||||
gepflegt**. Was von ihnen noch offen war, steht gebündelt in [`ROADMAP.md`](../../ROADMAP.md);
|
|
||||||
der Ist-Stand des Projekts in [`STATUS.md`](../../STATUS.md).
|
|
||||||
|
|
||||||
**Wozu sie trotzdem da sind:** Die Roadmap nennt jeden offenen Punkt knapp. Die Herleitung —
|
|
||||||
warum etwas so entschieden wurde, welche Messungen dahinterstehen, welche Alternativen
|
|
||||||
verworfen wurden — steht hier. Wer einen Roadmap-Punkt umsetzt, findet hier den Kontext.
|
|
||||||
|
|
||||||
**Vorsicht bei der Lektüre:** Der Text unterhalb des jeweiligen Statusblocks beschreibt den
|
|
||||||
Stand seiner Entstehungszeit. Test-Baselines, Dateipfade und „so ist es heute"-Aussagen sind
|
|
||||||
teils überholt. Immer gegen den Code prüfen, nie direkt übernehmen.
|
|
||||||
|
|
||||||
| Dokument | Stand | Was drinsteht |
|
|
||||||
|---|---|---|
|
|
||||||
| [`UMSETZUNGSPLAN.md`](UMSETZUNGSPLAN.md) | 2026-07-01, abgehakt 2026-08-23 | Phase 1 (Kernanalytik: PnL-Engine, Win-Rate, Deep-Dive, Copytrading-Score) und Phase 2 (Migrationen, Scoring-Entkopplung, Bugliste, Tests) sowie das Speicherplatz-Konzept. Bis auf A4 und B2 umgesetzt. |
|
|
||||||
| [`FIXPLAN-DONE.md`](FIXPLAN-DONE.md) | 2026-07-09 | Vorgängerfassung des Fix-Plans, vollständig abgearbeitet. Reine Historie. |
|
|
||||||
| [`FIXPLAN-TODO.md`](FIXPLAN-TODO.md) | 2026-07-09/11/13 | Teil D (Merkmals-Tags, HF-Trader-Tiering mit `IngestMode`) und Teil F (Kategorie-Erkennung: kanonische Tags, Rausch-Filter, Backfill). Umgesetzt bis auf F5 und zwei Tests. |
|
|
||||||
| [`FIXPLAN-G-Speicher.md`](FIXPLAN-G-Speicher.md) | 2026-07-17 | Speicher-Governor, Retention für aggregierte Trader, `UsdcSize`/`OutcomeIndex`, Per-Kategorie-Edge. Vollständig umgesetzt; der Anhang mit den einmaligen SQL-Optimierungen ist offen. |
|
|
||||||
| [`FIXPLAN-UI-Ranglisten.md`](FIXPLAN-UI-Ranglisten.md) | 2026-08-03 | Web-UI: Showcase-Sektionen, serverseitige Sortierung und Filterung. Vollständig umgesetzt bis auf die Sortierpfeile. |
|
|
||||||
| [`PLAN-Linux-Portierung.md`](PLAN-Linux-Portierung.md) | 2026-08-06 bis -08 | Der Portierungsplan mit allen Entscheidungen und Abnahmevermerken. **Die ausführlichste Quelle für Roadmap-Stufe 2** — Abschnitt 7 (Deployment) und 8 (Verifikationsmatrix) sind dort detaillierter als in der Roadmap. |
|
|
||||||
| [`ANALYSE-Linux-Portierung.md`](ANALYSE-Linux-Portierung.md) | 2026-08-06 | Die Bestandsaufnahme, die dem Portierungsplan vorausging: was am WinForms-Host hing, welche Pakete windows-spezifisch waren. Historisch. |
|
|
||||||
| [`PLAN-Architektur-WebUI-Backend.md`](PLAN-Architektur-WebUI-Backend.md) | 2026-07 | Drei-Schichten-Konzept Core/Lokal/Public als Vorbereitung einer Monetarisierung. Phase 0–2 sind im Zuge der Portierung erledigt; Phase 3–4 sind Roadmap-Stufe 6. |
|
|
||||||
| [`PLAN-DatenIngest-Skalierung.md`](PLAN-DatenIngest-Skalierung.md) | 2026-07 | Rate-Limits, Egress-Kanäle, Blockchain als Bulk-Quelle. Egress-Kanäle und Ingest-Tiering sind umgesetzt; der Rest ist Roadmap-Stufe 5. |
|
|
||||||
@@ -1,18 +1,48 @@
|
|||||||
using Predictalytics.Api.Endpoints;
|
using Predictalytics.Api.Endpoints;
|
||||||
using Predictalytics.Api.Security;
|
using Predictalytics.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Predictalytics.Api;
|
namespace Predictalytics.Api;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registriert die Endpunkte der lokalen API. Den WebApplication-Aufbau selbst
|
/// Configures the API WebApplication. Used by the embedded Kestrel server.
|
||||||
/// (Kestrel, CORS, Swagger, Static Files) macht Predictalytics.Hosting —
|
|
||||||
/// hier stehen nur die Routen.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ApiConfiguration
|
public static class ApiConfiguration
|
||||||
{
|
{
|
||||||
|
public static WebApplication ConfigureApi(WebApplicationBuilder builder, string? connectionStringOverride = null)
|
||||||
|
{
|
||||||
|
builder.Services.AddPredictalytics(builder.Configuration, connectionStringOverride);
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
||||||
|
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
||||||
|
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
|
||||||
|
?? new[] { "http://localhost:5000" };
|
||||||
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
||||||
|
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
|
||||||
|
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register both Read and Control endpoints for the local host
|
||||||
|
app.MapPredictalyticsReadEndpoints();
|
||||||
|
app.MapPredictalyticsControlEndpoints();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers all Read-only endpoints designed for viewing and analysis.
|
/// Registers all Read-only endpoints designed for viewing and analysis.
|
||||||
/// Safely exposed publicly since these endpoints perform no DB writes.
|
/// Safely exposed publicly since these endpoints perform no DB writes.
|
||||||
@@ -29,47 +59,23 @@ public static class ApiConfiguration
|
|||||||
routes.MapCoMovementEndpoints();
|
routes.MapCoMovementEndpoints();
|
||||||
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
routes.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
|
||||||
|
|
||||||
// Meldet der WebUI den tatsaechlichen Zustand, nicht eine Konfigurationsabsicht:
|
routes.MapGet("/api/capabilities", (IConfiguration config) =>
|
||||||
// ControlState wird beim Mappen der Steuerendpunkte gesetzt.
|
|
||||||
routes.MapGet("/api/capabilities", () => Results.Ok(new
|
|
||||||
{
|
{
|
||||||
CanControl = ControlEndpointsMapped,
|
var canControl = config.GetValue<bool>("ApiSettings:CanControl", true);
|
||||||
AuthRequired = ControlEndpointsRequireToken,
|
var authRequired = config.GetValue<bool>("ApiSettings:AuthRequired", false);
|
||||||
TokenHeader = ControlEndpointsRequireToken ? ApiTokenFilter.HeaderName : null
|
return Results.Ok(new { CanControl = canControl, AuthRequired = authRequired });
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ob die Steuerendpunkte ueberhaupt erreichbar sind.</summary>
|
|
||||||
public static bool ControlEndpointsMapped { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>Ob sie einen Token verlangen.</summary>
|
|
||||||
public static bool ControlEndpointsRequireToken { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
|
/// Registers all Control endpoints that trigger crawls, modifications, or database updates.
|
||||||
|
/// Excluded from the Public API routing to ensure system security.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="requiredToken">
|
public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes)
|
||||||
/// Wenn gesetzt, verlangt jeder Aufruf den Header
|
|
||||||
/// <see cref="ApiTokenFilter.HeaderName"/> mit diesem Wert. Null oder leer bedeutet
|
|
||||||
/// ungeschuetzt — das ist nur vertretbar, solange Kestrel an localhost gebunden ist.
|
|
||||||
/// </param>
|
|
||||||
public static void MapPredictalyticsControlEndpoints(this IEndpointRouteBuilder routes, string? requiredToken = null)
|
|
||||||
{
|
{
|
||||||
IEndpointRouteBuilder target = routes;
|
routes.MapTraderControlEndpoints();
|
||||||
|
routes.MapAlertControlEndpoints();
|
||||||
if (!string.IsNullOrWhiteSpace(requiredToken))
|
routes.MapJobEndpoints();
|
||||||
{
|
routes.MapDevEndpoints();
|
||||||
// Leere Gruppe ohne Praefix: sie dient nur dazu, den Filter an alle
|
|
||||||
// darin gemappten Endpunkte zu haengen.
|
|
||||||
target = routes.MapGroup("").AddEndpointFilter(new ApiTokenFilter(requiredToken!));
|
|
||||||
ControlEndpointsRequireToken = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.MapTraderControlEndpoints();
|
|
||||||
target.MapAlertControlEndpoints();
|
|
||||||
target.MapJobEndpoints();
|
|
||||||
target.MapDevEndpoints();
|
|
||||||
|
|
||||||
ControlEndpointsMapped = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public static class DevEndpoints
|
|||||||
// its raw SQL referenced non-existent columns/tables (Trades.Type/Payout,
|
// its raw SQL referenced non-existent columns/tables (Trades.Type/Payout,
|
||||||
// Traders.LastPositionsUpdatedAt, table "Jobs") and would have deleted ALL
|
// Traders.LastPositionsUpdatedAt, table "Jobs") and would have deleted ALL
|
||||||
// TraderPositions including the pruned-history conserves. The supported
|
// TraderPositions including the pruned-history conserves. The supported
|
||||||
// repair path is the "Recalculate All Traders" action in the Avalonia
|
// repair path is the "Recalculate All Traders" action in the WinForms
|
||||||
// Development menu (EmbeddedWebServer.RunRecalculateAllTradersAsync).
|
// Development menu (EmbeddedWebServer.RunRecalculateAllTradersAsync).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>Predictalytics.Api</RootNamespace>
|
<RootNamespace>Predictalytics.Api</RootNamespace>
|
||||||
<!-- Bibliothek fuer Predictalytics.Hosting, laeuft nicht eigenstaendig. -->
|
<!-- This project is used as a library by WinFormsHost; it does not run standalone -->
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
|
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace Predictalytics.Api.Security;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Schuetzt die schreibenden Endpunkte mit einem gemeinsamen Token.
|
|
||||||
/// <para>
|
|
||||||
/// Solange Kestrel nur auf localhost lauscht, ist das Betriebssystem die Absicherung.
|
|
||||||
/// Sobald der Dienst von aussen erreichbar ist, faellt diese weg — dann muss jeder
|
|
||||||
/// Aufruf, der etwas veraendert, den Header <see cref="HeaderName"/> mitschicken.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ApiTokenFilter : IEndpointFilter
|
|
||||||
{
|
|
||||||
public const string HeaderName = "X-Predictalytics-Key";
|
|
||||||
|
|
||||||
private readonly byte[] _expected;
|
|
||||||
|
|
||||||
public ApiTokenFilter(string token) => _expected = Encoding.UTF8.GetBytes(token);
|
|
||||||
|
|
||||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
|
||||||
{
|
|
||||||
var provided = context.HttpContext.Request.Headers[HeaderName].ToString();
|
|
||||||
|
|
||||||
if (!IsMatch(provided))
|
|
||||||
{
|
|
||||||
return Results.Json(
|
|
||||||
new { error = "unauthorized", message = $"Header {HeaderName} fehlt oder ist falsch." },
|
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await next(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Laufzeitkonstanter Vergleich: ein zeichenweise abbrechender Vergleich verriete
|
|
||||||
/// ueber die Antwortzeit, wie viele Zeichen bereits stimmen.
|
|
||||||
/// </summary>
|
|
||||||
private bool IsMatch(string provided)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(provided)) return false;
|
|
||||||
var actual = Encoding.UTF8.GetBytes(provided);
|
|
||||||
return CryptographicOperations.FixedTimeEquals(actual, _expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,7 @@
|
|||||||
},
|
},
|
||||||
"OpenRouter": {
|
"OpenRouter": {
|
||||||
"BaseUrl": "https://openrouter.ai/api/v1",
|
"BaseUrl": "https://openrouter.ai/api/v1",
|
||||||
"_ApiKey": "NIEMALS hier eintragen - der Schluessel gehoert in die Umgebungsvariable OpenRouter__ApiKey. Diese Datei ist versioniert.",
|
"ApiKey": "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"
|
||||||
"ApiKey": ""
|
|
||||||
},
|
},
|
||||||
"Egress": {
|
"Egress": {
|
||||||
"Channels": []
|
"Channels": []
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Predictalytics.Api.Security;
|
|
||||||
|
|
||||||
namespace Predictalytics.Application.Tests.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Der Filter ist das Einzige, was die schreibenden Endpunkte schuetzt, sobald der
|
|
||||||
/// Dienst ueber das Netz erreichbar ist.
|
|
||||||
/// </summary>
|
|
||||||
public class ApiTokenFilterTests
|
|
||||||
{
|
|
||||||
private const string Token = "s3cr3t-token";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Correct_token_passes_through()
|
|
||||||
{
|
|
||||||
var (context, next, called) = Build(Token);
|
|
||||||
var result = await new ApiTokenFilter(Token).InvokeAsync(context, next);
|
|
||||||
|
|
||||||
Assert.True(called.Value);
|
|
||||||
Assert.Equal("weiter", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("")] // Header fehlt
|
|
||||||
[InlineData("falsch")]
|
|
||||||
[InlineData("s3cr3t-toke")] // ein Zeichen zu kurz
|
|
||||||
[InlineData("s3cr3t-tokenX")] // ein Zeichen zu lang
|
|
||||||
[InlineData("S3CR3T-TOKEN")] // Schreibweise muss zaehlen
|
|
||||||
[InlineData(" s3cr3t-token")] // fuehrender Leerraum
|
|
||||||
public async Task Wrong_or_missing_token_is_rejected(string provided)
|
|
||||||
{
|
|
||||||
var (context, next, called) = Build(provided);
|
|
||||||
var result = await new ApiTokenFilter(Token).InvokeAsync(context, next);
|
|
||||||
|
|
||||||
Assert.False(called.Value);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
Assert.NotEqual("weiter", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (DefaultEndpointFilterInvocationContext Context, EndpointFilterDelegate Next, StrongBox<bool> Called)
|
|
||||||
Build(string headerValue)
|
|
||||||
{
|
|
||||||
var httpContext = new DefaultHttpContext();
|
|
||||||
if (headerValue.Length > 0)
|
|
||||||
{
|
|
||||||
httpContext.Request.Headers[ApiTokenFilter.HeaderName] = headerValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var called = new StrongBox<bool>(false);
|
|
||||||
EndpointFilterDelegate next = _ =>
|
|
||||||
{
|
|
||||||
called.Value = true;
|
|
||||||
return ValueTask.FromResult<object?>("weiter");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (new DefaultEndpointFilterInvocationContext(httpContext), next, called);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class StrongBox<T>(T value)
|
|
||||||
{
|
|
||||||
public T Value { get; set; } = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
using Predictalytics.Hosting;
|
|
||||||
|
|
||||||
namespace Predictalytics.Application.Tests.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="PredictalyticsOptions.IsPubliclyBound"/> entscheidet, ob die schreibenden
|
|
||||||
/// API-Endpunkte ueberhaupt bereitgestellt werden und ob Swagger offen liegt. Ein falsches
|
|
||||||
/// Ergebnis ist deshalb kein Schoenheitsfehler, sondern eine offene Steuerschnittstelle.
|
|
||||||
/// </summary>
|
|
||||||
public class PredictalyticsOptionsTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData("localhost")]
|
|
||||||
[InlineData("LOCALHOST")] // Schreibweise darf nicht entscheiden
|
|
||||||
[InlineData("LocalHost")]
|
|
||||||
[InlineData("127.0.0.1")]
|
|
||||||
[InlineData("::1")]
|
|
||||||
[InlineData(" localhost ")] // Leerraum aus dem Eingabefeld
|
|
||||||
[InlineData("")] // leer = Vorgabe = lokal
|
|
||||||
[InlineData(" ")]
|
|
||||||
public void IsPubliclyBound_is_false_for_loopback(string host)
|
|
||||||
{
|
|
||||||
var options = new PredictalyticsOptions { WebserverHost = host };
|
|
||||||
Assert.False(options.IsPubliclyBound);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("0.0.0.0")]
|
|
||||||
[InlineData("::")]
|
|
||||||
[InlineData("192.168.178.10")]
|
|
||||||
[InlineData("10.0.0.5")]
|
|
||||||
[InlineData("predictalytics.example.com")]
|
|
||||||
public void IsPubliclyBound_is_true_for_everything_else(string host)
|
|
||||||
{
|
|
||||||
var options = new PredictalyticsOptions { WebserverHost = host };
|
|
||||||
Assert.True(options.IsPubliclyBound);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WebserverUrl_falls_back_to_localhost_when_host_is_blank()
|
|
||||||
{
|
|
||||||
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 5000 };
|
|
||||||
Assert.Equal("http://localhost:5000", options.WebserverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WebserverUrl_combines_host_and_port()
|
|
||||||
{
|
|
||||||
var options = new PredictalyticsOptions { WebserverHost = "0.0.0.0", WebserverPort = 8080 };
|
|
||||||
Assert.Equal("http://0.0.0.0:8080", options.WebserverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ConnectionString_honours_the_configured_ssl_mode()
|
|
||||||
{
|
|
||||||
var options = new PredictalyticsOptions
|
|
||||||
{
|
|
||||||
DbServer = "db.example.com",
|
|
||||||
DbName = "predictalytics",
|
|
||||||
DbUser = "app",
|
|
||||||
DbPassword = "secret",
|
|
||||||
DbSslMode = MySqlConnector.MySqlSslMode.Preferred
|
|
||||||
};
|
|
||||||
|
|
||||||
var builder = new MySqlConnector.MySqlConnectionStringBuilder(options.ConnectionString);
|
|
||||||
Assert.Equal(MySqlConnector.MySqlSslMode.Preferred, builder.SslMode);
|
|
||||||
Assert.Equal("predictalytics", builder.Database);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Die Setter ignorieren leere Werte bewusst — sonst wuerde ein versehentlich geleertes
|
|
||||||
/// Eingabefeld die hinterlegten Zugangsdaten verwerfen.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Blank_database_values_do_not_overwrite_existing_ones()
|
|
||||||
{
|
|
||||||
var options = new PredictalyticsOptions { DbName = "predictalytics", DbUser = "app" };
|
|
||||||
|
|
||||||
options.DbName = "";
|
|
||||||
options.DbUser = " ";
|
|
||||||
|
|
||||||
Assert.Equal("predictalytics", options.DbName);
|
|
||||||
Assert.Equal("app", options.DbUser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="Moq" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -23,7 +26,6 @@
|
|||||||
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
|
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
||||||
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -16,6 +16,7 @@ public interface IAnalyticsService
|
|||||||
Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Services.ShowcaseSection>> GetShowcasesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Get list of all discovered traits.</summary>
|
/// <summary>Get list of all discovered traits.</summary>
|
||||||
|
Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Get a trader's details.</summary>
|
/// <summary>Get a trader's details.</summary>
|
||||||
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
|
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -5,12 +5,5 @@ namespace Predictalytics.Application.Interfaces;
|
|||||||
|
|
||||||
public interface IOpenRouterApiClient
|
public interface IOpenRouterApiClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Rueckgabe, wenn kein API-Schluessel hinterlegt ist. Aufrufer pruefen darauf, statt
|
|
||||||
/// die Antwort als JSON zu lesen - sonst erzeugt eine fehlende Konfiguration bei jedem
|
|
||||||
/// Versuch einen Parse-Fehler im Log.
|
|
||||||
/// </summary>
|
|
||||||
const string NotConfigured = "OpenRouter ist nicht konfiguriert.";
|
|
||||||
|
|
||||||
Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default);
|
Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>Predictalytics.Application</RootNamespace>
|
<RootNamespace>Predictalytics.Application</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -45,14 +45,6 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
|
|||||||
|
|
||||||
var jsonResult = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct);
|
var jsonResult = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct);
|
||||||
|
|
||||||
// Ohne Schluessel gar nicht erst parsen: das erzeugte sonst pro Versuch einen
|
|
||||||
// Error-Eintrag, obwohl nur die Konfiguration fehlt.
|
|
||||||
if (jsonResult == IOpenRouterApiClient.NotConfigured)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("KI-Analyse fuer {TraderName} uebersprungen: OpenRouter ist nicht konfiguriert.", trader.DisplayName);
|
|
||||||
return jsonResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Remove markdown code blocks if present
|
// Remove markdown code blocks if present
|
||||||
|
|||||||
@@ -242,6 +242,13 @@ public class AnalyticsService : IAnalyticsService
|
|||||||
return ShowcaseBuilder.Build(candidates);
|
return ShowcaseBuilder.Build(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Actually this is unused since we mapped it straight to db context in the endpoint,
|
||||||
|
// but required by interface.
|
||||||
|
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, string? category = null, string? query = null, CancellationToken ct = default)
|
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, string? category = null, string? query = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
|
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>Predictalytics.Domain</RootNamespace>
|
<RootNamespace>Predictalytics.Domain</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Failed Deployment Center call. <see cref="Code"/> is the stable, machine readable
|
|
||||||
/// error code from the API envelope ("unauthorized", "rate_limited", ...) — react to it,
|
|
||||||
/// not to the message text.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DcApiException : Exception
|
|
||||||
{
|
|
||||||
public DcApiException(HttpStatusCode statusCode, string? code, string body)
|
|
||||||
: base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}")
|
|
||||||
{
|
|
||||||
StatusCode = statusCode;
|
|
||||||
Code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpStatusCode StatusCode { get; }
|
|
||||||
public string? Code { get; }
|
|
||||||
|
|
||||||
/// <summary>True for errors that repeating the same call cannot fix (wrong/missing token).</summary>
|
|
||||||
public bool IsPermanent =>
|
|
||||||
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
|
|
||||||
Code is "unauthorized" or "project_forbidden";
|
|
||||||
|
|
||||||
private static string Shorten(string body) =>
|
|
||||||
body.Length <= 300 ? body : body[..300] + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal JSON client for the Deployment Center API. Used by the heartbeat and the error
|
|
||||||
/// stream; the license and update modules bring their own client (Deploymentcenter.Client).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DcApiClient : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
|
||||||
private readonly string _token;
|
|
||||||
|
|
||||||
public DcApiClient(string token, TimeSpan? timeout = null)
|
|
||||||
{
|
|
||||||
_token = token ?? "";
|
|
||||||
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> PostJsonAsync(string path, object payload, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path);
|
|
||||||
if (!string.IsNullOrWhiteSpace(_token))
|
|
||||||
{
|
|
||||||
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}");
|
|
||||||
}
|
|
||||||
request.Content = new StringContent(
|
|
||||||
JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
|
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads error.code out of {"status":"error","error":{"code":"…"}}.</summary>
|
|
||||||
private static string? ExtractErrorCode(string body)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
|
|
||||||
doc.RootElement.TryGetProperty("error", out var error) &&
|
|
||||||
error.ValueKind == JsonValueKind.Object &&
|
|
||||||
error.TryGetProperty("code", out var code))
|
|
||||||
{
|
|
||||||
return code.GetString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Not every error path answers with the envelope (e.g. a proxy returning HTML).
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() => _http.Dispose();
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Compile-time settings for the Deployment Center (https://dc.mhdf.de), which replaced
|
|
||||||
/// the separate Watchdog and LicenseLabrador servers.
|
|
||||||
///
|
|
||||||
/// The base URL is deliberately NOT user configuration: it decides where the license check
|
|
||||||
/// goes and where update packages are downloaded from. A configurable endpoint would let
|
|
||||||
/// anyone point the app at a fake license or update server.
|
|
||||||
/// </summary>
|
|
||||||
public static class DcConfig
|
|
||||||
{
|
|
||||||
public const string BaseUrl = "https://dc.mhdf.de";
|
|
||||||
|
|
||||||
/// <summary>Slug in dc_projects — license, UpdateService, error stream and bugtracker share it.</summary>
|
|
||||||
public const string ProductSlug = "predictalytics";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reported to the license activation list, the monitor and the error stream.
|
|
||||||
/// Generated by Deploymentcenter.BuildInfo.targets from <Version> in the csproj —
|
|
||||||
/// bump it there when releasing, it is what the UpdateService compares against.
|
|
||||||
/// </summary>
|
|
||||||
public static string AppVersion => BuildInfo.Version;
|
|
||||||
|
|
||||||
/// <summary>Commit this build came from — travels with error reports.</summary>
|
|
||||||
public static string GitCommitShort => BuildInfo.GitCommitShort;
|
|
||||||
|
|
||||||
/// <summary>Dashboard grouping of the Watchdog monitor.</summary>
|
|
||||||
public const string MonitorGroup = "Applications";
|
|
||||||
|
|
||||||
/// <summary>Value for the "environment" field of the error stream.</summary>
|
|
||||||
#if DEBUG
|
|
||||||
public const string Environment = "development";
|
|
||||||
#else
|
|
||||||
public const string Environment = "production";
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Forwards runtime errors to the Deployment Center error stream
|
|
||||||
/// (POST /api/errors/v1/report). The server groups identical errors, counts them up and
|
|
||||||
/// silences known-harmless ones via ignore rules — so this reports rather than filters.
|
|
||||||
///
|
|
||||||
/// Static on purpose: the Serilog sink and the global exception handlers are wired up
|
|
||||||
/// before the settings are known, and both have to reach the same rate limiter.
|
|
||||||
/// </summary>
|
|
||||||
public static class DcErrorReporter
|
|
||||||
{
|
|
||||||
/// <summary>Server limit is 60 reports per minute and IP — stay well below it.</summary>
|
|
||||||
private const int MaxReportsPerMinute = 20;
|
|
||||||
|
|
||||||
/// <summary>The same error is only reported again after this interval (the server counts it up anyway).</summary>
|
|
||||||
private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5);
|
|
||||||
|
|
||||||
private static readonly object Sync = new();
|
|
||||||
private static readonly Dictionary<string, DateTime> RecentSignatures = new();
|
|
||||||
|
|
||||||
private static DcApiClient? _api;
|
|
||||||
private static bool _enabled;
|
|
||||||
private static bool _tokenRejected;
|
|
||||||
private static bool _transportFailureLogged;
|
|
||||||
private static DateTime _windowStartUtc = DateTime.UtcNow;
|
|
||||||
private static int _sentInWindow;
|
|
||||||
|
|
||||||
public static bool IsEnabled => _enabled && _api is not null && !_tokenRejected;
|
|
||||||
|
|
||||||
/// <summary>(Re-)configures the reporter. An empty token switches it off.</summary>
|
|
||||||
public static void Configure(string token, bool enabled)
|
|
||||||
{
|
|
||||||
string? offReason;
|
|
||||||
lock (Sync)
|
|
||||||
{
|
|
||||||
_api?.Dispose();
|
|
||||||
_api = null;
|
|
||||||
_tokenRejected = false;
|
|
||||||
_transportFailureLogged = false;
|
|
||||||
|
|
||||||
if (!enabled || string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
_enabled = false;
|
|
||||||
offReason = !enabled
|
|
||||||
? "in den Einstellungen abgeschaltet"
|
|
||||||
: "kein Deployment-Center-Token hinterlegt";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_api = new DcApiClient(token);
|
|
||||||
_enabled = true;
|
|
||||||
offReason = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auf Warning-Ebene, damit es im Terminal des Hosts steht: ein stumm
|
|
||||||
// abgeschalteter Fehler-Stream faellt sonst erst auf, wenn im Deployment
|
|
||||||
// Center nach einem Absturz nichts ankommt. Warning erreicht den
|
|
||||||
// DcErrorSink nicht (Error+), erzeugt also keine Rueckkopplung.
|
|
||||||
if (offReason is null)
|
|
||||||
{
|
|
||||||
Log.Warning("🛰 Fehler-Stream aktiv — Error/Fatal gehen an {Url} (Projekt {Slug}, Build {Build}).",
|
|
||||||
DcConfig.BaseUrl, DcConfig.ProductSlug, DcConfig.AppVersion);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Warning("🛰 Fehler-Stream INAKTIV ({Reason}) — Laufzeitfehler bleiben nur lokal im Log.", offReason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Shutdown()
|
|
||||||
{
|
|
||||||
lock (Sync)
|
|
||||||
{
|
|
||||||
_enabled = false;
|
|
||||||
_api?.Dispose();
|
|
||||||
_api = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Installs the global handlers. Without them an unhandled exception ends the process
|
|
||||||
/// without a trace in the error stream — exactly the case the stream exists for.
|
|
||||||
/// </summary>
|
|
||||||
public static void InstallGlobalHandlers()
|
|
||||||
{
|
|
||||||
// Der UI-Thread-Handler bleibt beim Host: jedes Fenstersystem hat sein eigenes
|
|
||||||
// Ereignis dafuer (WinForms Application.ThreadException, Avalonia
|
|
||||||
// Dispatcher.UIThread.UnhandledException). Der Host ruft dafuer
|
|
||||||
// ReportUiThreadException auf.
|
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.ExceptionObject is not Exception ex) return;
|
|
||||||
// Report before logging: the Serilog sink would report the same exception first
|
|
||||||
// and the duplicate suppression would then swallow the blocking call — the
|
|
||||||
// process would die before anything reached the server.
|
|
||||||
Report(ex, "fatal", blocking: true);
|
|
||||||
Log.Fatal(ex, "Unbehandelte Ausnahme — Prozess wird beendet");
|
|
||||||
};
|
|
||||||
|
|
||||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
|
||||||
{
|
|
||||||
Log.Warning(e.Exception, "Unbeobachtete Task-Ausnahme");
|
|
||||||
Report(e.Exception, "error");
|
|
||||||
e.SetObserved();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Vom Host aufzurufen, wenn im UI-Thread eine unbehandelte Ausnahme auftritt.
|
|
||||||
/// Protokolliert und meldet sie; die Meldung an den Benutzer bleibt beim Host,
|
|
||||||
/// weil dafuer ein Fenster gebraucht wird.
|
|
||||||
/// </summary>
|
|
||||||
public static void ReportUiThreadException(Exception exception)
|
|
||||||
{
|
|
||||||
Log.Error(exception, "Unbehandelte Ausnahme im UI-Thread");
|
|
||||||
Report(exception, "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Report(Exception exception, string level = "error", bool blocking = false)
|
|
||||||
{
|
|
||||||
if (!IsEnabled) return;
|
|
||||||
|
|
||||||
var inner = Unwrap(exception);
|
|
||||||
var (file, line) = ResolveOrigin(inner);
|
|
||||||
|
|
||||||
Send(
|
|
||||||
exceptionType: inner.GetType().FullName ?? inner.GetType().Name,
|
|
||||||
message: inner.Message,
|
|
||||||
stackTrace: exception.ToString(),
|
|
||||||
level: level,
|
|
||||||
file: file,
|
|
||||||
line: line,
|
|
||||||
blocking: blocking);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reports a logged error that carries no exception (Log.Error("..." )).</summary>
|
|
||||||
public static void ReportMessage(string exceptionType, string message, string? stackTrace, string level)
|
|
||||||
{
|
|
||||||
if (!IsEnabled) return;
|
|
||||||
Send(exceptionType, message, stackTrace, level, null, null, blocking: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Send(
|
|
||||||
string exceptionType, string message, string? stackTrace, string level,
|
|
||||||
string? file, int? line, bool blocking)
|
|
||||||
{
|
|
||||||
DcApiClient api;
|
|
||||||
lock (Sync)
|
|
||||||
{
|
|
||||||
if (_api is null || !_enabled || _tokenRejected) return;
|
|
||||||
if (!PassesRateLimit(exceptionType, message)) return;
|
|
||||||
api = _api;
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
project_slug = DcConfig.ProductSlug,
|
|
||||||
exception = exceptionType,
|
|
||||||
message = Truncate(message, 2000),
|
|
||||||
stack_trace = Truncate(stackTrace, 8000),
|
|
||||||
level,
|
|
||||||
build = DcConfig.AppVersion,
|
|
||||||
environment = DcConfig.Environment,
|
|
||||||
file,
|
|
||||||
line,
|
|
||||||
// Tells apart reports coming from several installations of the same build,
|
|
||||||
// and pins the report to an exact commit.
|
|
||||||
context = new { host = System.Environment.MachineName, commit = DcConfig.GitCommitShort }
|
|
||||||
};
|
|
||||||
|
|
||||||
var task = PostAsync(api, payload);
|
|
||||||
if (blocking)
|
|
||||||
{
|
|
||||||
task.Wait(TimeSpan.FromSeconds(5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task PostAsync(DcApiClient api, object payload)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await api.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (DcApiException ex) when (ex.IsPermanent)
|
|
||||||
{
|
|
||||||
lock (Sync) { _tokenRejected = true; }
|
|
||||||
// Warning, nicht Error: der DcErrorSink nimmt erst ab Error an, ein Error
|
|
||||||
// hier kaeme als naechster Report zurueck. _tokenRejected sperrt weitere
|
|
||||||
// Versuche, die Meldung erscheint also genau einmal.
|
|
||||||
Log.Warning("🛰 Fehler-Stream abgewiesen ({Code}) — Token pruefen (Recht 'bugtracker:report'). Melden ist bis zum naechsten Speichern der Einstellungen aus.", ex.Code);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Erster Fehlschlag sichtbar, danach still: sonst haengt an jedem
|
|
||||||
// Netzwerkaussetzer eine Warnung pro gemeldetem Fehler.
|
|
||||||
bool first;
|
|
||||||
lock (Sync)
|
|
||||||
{
|
|
||||||
first = !_transportFailureLogged;
|
|
||||||
_transportFailureLogged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (first)
|
|
||||||
{
|
|
||||||
Log.Warning("🛰 Fehlermeldung an das Deployment Center fehlgeschlagen: {Message}. Weitere Fehlschlaege werden nicht mehr gemeldet.", ex.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Deployment Center error report failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Local budget: the server counts duplicates itself, we only avoid burning the rate limit.</summary>
|
|
||||||
private static bool PassesRateLimit(string exceptionType, string message)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (now - _windowStartUtc > TimeSpan.FromMinutes(1))
|
|
||||||
{
|
|
||||||
_windowStartUtc = now;
|
|
||||||
_sentInWindow = 0;
|
|
||||||
}
|
|
||||||
if (_sentInWindow >= MaxReportsPerMinute) return false;
|
|
||||||
|
|
||||||
var signature = exceptionType + "|" + Truncate(message, 200);
|
|
||||||
if (RecentSignatures.TryGetValue(signature, out var last) && now - last < RepeatSuppression)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RecentSignatures.Count > 500) RecentSignatures.Clear();
|
|
||||||
RecentSignatures[signature] = now;
|
|
||||||
_sentInWindow++;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// AggregateException and TargetInvocationException say nothing about the actual defect;
|
|
||||||
/// grouping on them would throw unrelated errors into one bucket.
|
|
||||||
/// </summary>
|
|
||||||
private static Exception Unwrap(Exception exception)
|
|
||||||
{
|
|
||||||
while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate)
|
|
||||||
{
|
|
||||||
exception = aggregate.InnerExceptions[0];
|
|
||||||
}
|
|
||||||
return exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads file and line from the first stack frame that has debug info (PDB present).</summary>
|
|
||||||
private static (string? File, int? Line) ResolveOrigin(Exception exception)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var trace = new StackTrace(exception, fNeedFileInfo: true);
|
|
||||||
foreach (var frame in trace.GetFrames())
|
|
||||||
{
|
|
||||||
var file = frame.GetFileName();
|
|
||||||
if (string.IsNullOrEmpty(file)) continue;
|
|
||||||
var lineNo = frame.GetFileLineNumber();
|
|
||||||
return (ToRepoRelative(file!), lineNo > 0 ? lineNo : (int?)null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Origin is a nicety, never a reason to drop the report.
|
|
||||||
}
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo.</summary>
|
|
||||||
private static string ToRepoRelative(string path)
|
|
||||||
{
|
|
||||||
var normalized = path.Replace('\\', '/');
|
|
||||||
var marker = normalized.LastIndexOf("/src/", StringComparison.OrdinalIgnoreCase);
|
|
||||||
return marker >= 0 ? normalized[(marker + 1)..] : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? Truncate(string? value, int max)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return value;
|
|
||||||
return value!.Length <= max ? value : value[..max] + "…";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using Serilog.Core;
|
|
||||||
using Serilog.Events;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serilog sink that forwards Error and Fatal events to the Deployment Center error stream.
|
|
||||||
///
|
|
||||||
/// It is registered while the logger is being built, long before the settings are known —
|
|
||||||
/// <see cref="DcErrorReporter"/> is asked at emit time whether reporting is switched on, so
|
|
||||||
/// toggling the setting takes effect without rebuilding the logger.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DcErrorSink : ILogEventSink
|
|
||||||
{
|
|
||||||
public void Emit(LogEvent logEvent)
|
|
||||||
{
|
|
||||||
if (logEvent.Level < LogEventLevel.Error) return;
|
|
||||||
if (!DcErrorReporter.IsEnabled) return;
|
|
||||||
|
|
||||||
var level = logEvent.Level == LogEventLevel.Fatal ? "fatal" : "error";
|
|
||||||
|
|
||||||
if (logEvent.Exception is not null)
|
|
||||||
{
|
|
||||||
DcErrorReporter.Report(logEvent.Exception, level);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No exception attached: the rendered message is all the identity this error has.
|
|
||||||
var source = logEvent.Properties.TryGetValue("SourceContext", out var ctx)
|
|
||||||
? ctx.ToString().Trim('"')
|
|
||||||
: "Predictalytics";
|
|
||||||
|
|
||||||
DcErrorReporter.ReportMessage(
|
|
||||||
exceptionType: source,
|
|
||||||
message: logEvent.RenderMessage(),
|
|
||||||
stackTrace: null,
|
|
||||||
level: level);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>Single self-assessed health check sent along with a heartbeat.</summary>
|
|
||||||
public sealed class DcCheck
|
|
||||||
{
|
|
||||||
public DcCheck(bool ok, string? message = null, double? value = null)
|
|
||||||
{
|
|
||||||
Ok = ok;
|
|
||||||
Message = message;
|
|
||||||
Value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
[JsonPropertyName("ok")] public bool Ok { get; }
|
|
||||||
[JsonPropertyName("message")] public string? Message { get; }
|
|
||||||
[JsonPropertyName("value")] public double? Value { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>What the application reports about itself in one heartbeat.</summary>
|
|
||||||
public sealed class DcHeartbeatSnapshot
|
|
||||||
{
|
|
||||||
/// <summary>ok | warning | error — "stopped"/"maintenance" are sent by the service itself.</summary>
|
|
||||||
public string Status { get; set; } = "ok";
|
|
||||||
|
|
||||||
public string? Message { get; set; }
|
|
||||||
|
|
||||||
/// <summary>A failing check downgrades an "ok" heartbeat to "warning" on the server.</summary>
|
|
||||||
public Dictionary<string, DcCheck> Checks { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Numeric values; the server keeps 14 days of history per metric.</summary>
|
|
||||||
public Dictionary<string, double> Metrics { get; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sends periodic heartbeats to the Deployment Center Watchdog
|
|
||||||
/// (POST /api/watchdog/v1/ping) so an outage of this app — or of the whole machine —
|
|
||||||
/// raises an alarm. A Deployment Center outage must never impact the app: every call
|
|
||||||
/// is best effort.
|
|
||||||
///
|
|
||||||
/// The server evaluates by interval: no heartbeat for more than 2× the interval means
|
|
||||||
/// "warning", more than 4× means "down".
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DcHeartbeatService : IDisposable
|
|
||||||
{
|
|
||||||
private readonly DcApiClient _api;
|
|
||||||
private readonly string _source;
|
|
||||||
private readonly string _instance;
|
|
||||||
private readonly int _intervalSeconds;
|
|
||||||
private readonly Func<CancellationToken, Task<DcHeartbeatSnapshot>>? _snapshotProvider;
|
|
||||||
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Guards against overlapping sends when a call takes longer than the interval.
|
|
||||||
private readonly SemaphoreSlim _sendGate = new(1, 1);
|
|
||||||
|
|
||||||
private System.Threading.Timer? _timer;
|
|
||||||
private bool _lastSendFailed;
|
|
||||||
private bool _tokenRejected;
|
|
||||||
private bool _stoppingNotified;
|
|
||||||
private volatile bool _disposed;
|
|
||||||
|
|
||||||
public DcHeartbeatService(
|
|
||||||
string token,
|
|
||||||
string source,
|
|
||||||
string instance,
|
|
||||||
int intervalSeconds,
|
|
||||||
Func<CancellationToken, Task<DcHeartbeatSnapshot>>? snapshotProvider = null)
|
|
||||||
{
|
|
||||||
_api = new DcApiClient(token);
|
|
||||||
_source = string.IsNullOrWhiteSpace(source) ? "Predictalytics" : source;
|
|
||||||
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
|
|
||||||
_intervalSeconds = Math.Max(15, intervalSeconds);
|
|
||||||
_snapshotProvider = snapshotProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = new System.Threading.Timer(
|
|
||||||
async _ => await SendHeartbeatAsync().ConfigureAwait(false),
|
|
||||||
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
|
|
||||||
Log.Information("🐕 Deployment Center Heartbeat gestartet → {Url} (source={Source}, alle {Interval}s)",
|
|
||||||
DcConfig.BaseUrl, _source, _intervalSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Announces a planned shutdown as status "stopped". The evaluator leaves such a monitor
|
|
||||||
/// alone until a normal heartbeat arrives again — without it, every orderly shutdown
|
|
||||||
/// produces a false alarm a few minutes later.
|
|
||||||
/// </summary>
|
|
||||||
public void NotifyStopping()
|
|
||||||
{
|
|
||||||
if (_stoppingNotified) return;
|
|
||||||
_stoppingNotified = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = null;
|
|
||||||
|
|
||||||
var payload = BuildPayload("stopped", "Predictalytics wird planmäßig beendet.", null, null);
|
|
||||||
// Synchronous with a short cap: the form is closing and must not hang.
|
|
||||||
_api.PostJsonAsync("/api/watchdog/v1/ping", payload).Wait(TimeSpan.FromSeconds(4));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Best effort only.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Invoked from a timer callback, i.e. as async void — an exception escaping here would
|
|
||||||
/// take the whole process down. Nothing in this method may throw.
|
|
||||||
/// </remarks>
|
|
||||||
private async Task SendHeartbeatAsync()
|
|
||||||
{
|
|
||||||
if (_disposed || _tokenRejected) return;
|
|
||||||
if (!await _sendGate.WaitAsync(0).ConfigureAwait(false)) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_intervalSeconds));
|
|
||||||
|
|
||||||
DcHeartbeatSnapshot snapshot;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
snapshot = _snapshotProvider is null
|
|
||||||
? new DcHeartbeatSnapshot()
|
|
||||||
: await _snapshotProvider(cts.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Collecting the state must never keep the heartbeat from going out —
|
|
||||||
// that would turn a diagnostic hiccup into a false "down".
|
|
||||||
snapshot = new DcHeartbeatSnapshot { Status = "warning", Message = $"Statusermittlung fehlgeschlagen: {ex.Message}" };
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot.Metrics["uptime_sec"] = Math.Round((DateTime.UtcNow - _startedUtc).TotalSeconds);
|
|
||||||
|
|
||||||
var payload = BuildPayload(snapshot.Status, snapshot.Message, snapshot.Checks, snapshot.Metrics);
|
|
||||||
await _api.PostJsonAsync("/api/watchdog/v1/ping", payload, cts.Token).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (_lastSendFailed)
|
|
||||||
{
|
|
||||||
_lastSendFailed = false;
|
|
||||||
Log.Information("🐕 Deployment Center Heartbeat wieder erfolgreich zugestellt.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (DcApiException ex) when (ex.IsPermanent)
|
|
||||||
{
|
|
||||||
// Retrying cannot help — a rejected token would otherwise log forever.
|
|
||||||
_tokenRejected = true;
|
|
||||||
Log.Warning("🐕 Deployment Center weist das Token zurück ({Code}) — Heartbeats werden eingestellt. " +
|
|
||||||
"Bitte in den Settings ein Token mit dem Recht 'watchdog:ping' eintragen.", ex.Code ?? "unauthorized");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Log the first failure as warning, subsequent ones quietly (no log flood).
|
|
||||||
if (!_lastSendFailed)
|
|
||||||
{
|
|
||||||
_lastSendFailed = true;
|
|
||||||
Log.Warning("🐕 Deployment Center Heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Deployment Center heartbeat failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_sendGate.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_disposed = true;
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = null;
|
|
||||||
|
|
||||||
// Give a send in flight a moment to finish before the HttpClient goes away. Called
|
|
||||||
// from the UI thread (settings change, form closing), so the wait stays short — if it
|
|
||||||
// expires, the pending call just fails and gets logged like any other network error.
|
|
||||||
if (_sendGate.Wait(TimeSpan.FromMilliseconds(250)))
|
|
||||||
{
|
|
||||||
_sendGate.Release();
|
|
||||||
}
|
|
||||||
_api.Dispose();
|
|
||||||
|
|
||||||
// _sendGate is deliberately not disposed: a late Release() on a disposed semaphore
|
|
||||||
// would throw on the timer thread for no gain — SemaphoreSlim without a wait handle
|
|
||||||
// holds no unmanaged resources.
|
|
||||||
}
|
|
||||||
|
|
||||||
private object BuildPayload(
|
|
||||||
string status,
|
|
||||||
string? message,
|
|
||||||
Dictionary<string, DcCheck>? checks,
|
|
||||||
Dictionary<string, double>? metrics) => new
|
|
||||||
{
|
|
||||||
source = _source,
|
|
||||||
instance = _instance,
|
|
||||||
type = "heartbeat",
|
|
||||||
status,
|
|
||||||
interval = _intervalSeconds,
|
|
||||||
message,
|
|
||||||
group = DcConfig.MonitorGroup,
|
|
||||||
os = $"{RuntimeInformation.OSDescription} / .NET {System.Environment.Version}",
|
|
||||||
// Since 2.1 the monitor can show which build is running — that is what ties
|
|
||||||
// "this monitor went down" to "we rolled out 1.4.3 an hour ago".
|
|
||||||
version = DcConfig.AppVersion,
|
|
||||||
checks = checks is { Count: > 0 } ? checks : null,
|
|
||||||
metrics = metrics is { Count: > 0 } ? metrics : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
using Deploymentcenter.Client;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks the Deployment Center UpdateService for a newer release
|
|
||||||
/// (GET /api/updateservice/v1/check).
|
|
||||||
///
|
|
||||||
/// The check itself only reads metadata. Installing is done by the standalone
|
|
||||||
/// update-agent, which replaces the running installation and therefore has to be
|
|
||||||
/// started explicitly by the user.
|
|
||||||
///
|
|
||||||
/// Since Deployment Center 2.4 the release directories sit behind HTTP basic auth:
|
|
||||||
/// the license key is the credential. Without it every download answers 401, so the
|
|
||||||
/// key has to travel with both the check and the agent handover.
|
|
||||||
/// </summary>
|
|
||||||
public static class DcUpdateService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Runtime identifier the release directories are split by (win-x64, linux-x64, …).
|
|
||||||
/// Taken from the SDK so app and agent agree on the spelling.
|
|
||||||
/// </summary>
|
|
||||||
public static string Platform => PlatformId.Current;
|
|
||||||
|
|
||||||
public static async Task<UpdateCheckResult> CheckAsync(
|
|
||||||
string channel, string? licenseKey = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var client = new UpdateClient();
|
|
||||||
return await client.CheckForUpdateAsync(
|
|
||||||
baseUrl: DcConfig.BaseUrl,
|
|
||||||
projectId: DcConfig.ProductSlug,
|
|
||||||
currentVersion: DcConfig.AppVersion,
|
|
||||||
channel: NormalizeChannel(channel),
|
|
||||||
platform: Platform,
|
|
||||||
credentials: ResolveCredentials(licenseKey),
|
|
||||||
cancellationToken: ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
|
|
||||||
public static string? FindUpdateAgent()
|
|
||||||
{
|
|
||||||
return UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hands control to the update agent and ends this process. Returns false if the agent
|
|
||||||
/// is not present — then the update has to be installed by hand.
|
|
||||||
/// </summary>
|
|
||||||
public static bool LaunchAgent(string channel, string? licenseKey = null)
|
|
||||||
{
|
|
||||||
var agent = FindUpdateAgent();
|
|
||||||
if (agent is null)
|
|
||||||
{
|
|
||||||
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.",
|
|
||||||
UpdateClient.AgentFileName);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = ResolveLicenseKey(licenseKey);
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
// The agent would download, get 401 and abort — with the app already closed.
|
|
||||||
Log.Warning("Kein Lizenzschlüssel verfügbar — der Update-Agent käme nicht an das Paket. " +
|
|
||||||
"Update wird nicht gestartet.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var restartPath = ResolveRestartPath();
|
|
||||||
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}, Plattform {Platform}) und beende die Anwendung...",
|
|
||||||
agent, NormalizeChannel(channel), Platform);
|
|
||||||
|
|
||||||
return UpdateClient.LaunchUpdateAgent(
|
|
||||||
agentPath: agent,
|
|
||||||
projectId: DcConfig.ProductSlug,
|
|
||||||
channel: NormalizeChannel(channel),
|
|
||||||
action: "update",
|
|
||||||
version: "latest",
|
|
||||||
exitCurrentApp: true,
|
|
||||||
restartPath: restartPath,
|
|
||||||
currentVersion: DcConfig.AppVersion,
|
|
||||||
platform: Platform,
|
|
||||||
// The agent waits for this process to go away before it copies over
|
|
||||||
// the installation — otherwise it writes onto still-locked assemblies.
|
|
||||||
waitForCurrentProcess: true,
|
|
||||||
licenseKey: key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Basic-auth credentials for the protected release directories. Falls back to the key
|
|
||||||
/// of the last successful activation, which also covers the headless path.
|
|
||||||
/// </summary>
|
|
||||||
private static ReleaseCredentials? ResolveCredentials(string? licenseKey)
|
|
||||||
=> ReleaseCredentials.FromLicenseKey(ResolveLicenseKey(licenseKey));
|
|
||||||
|
|
||||||
private static string? ResolveLicenseKey(string? licenseKey)
|
|
||||||
=> string.IsNullOrWhiteSpace(licenseKey)
|
|
||||||
? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug)
|
|
||||||
: licenseKey;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executable the agent should start again once it is done.
|
|
||||||
///
|
|
||||||
/// Passing null is not an option: the SDK then falls back to Environment.ProcessPath
|
|
||||||
/// itself, so the "dotnet" case below cannot be opted out of — it has to be resolved
|
|
||||||
/// to the apphost here.
|
|
||||||
/// </summary>
|
|
||||||
private static string? ResolveRestartPath()
|
|
||||||
{
|
|
||||||
var path = Environment.ProcessPath;
|
|
||||||
|
|
||||||
// Started as "dotnet Predictalytics.Shell.dll": ProcessPath is the shared runtime
|
|
||||||
// host, and restarting that without its argument would launch nothing. The apphost
|
|
||||||
// next to the entry assembly is what the user actually started from.
|
|
||||||
if (path is null || Path.GetFileNameWithoutExtension(path).Equals("dotnet", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var entry = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
|
|
||||||
if (!string.IsNullOrWhiteSpace(entry))
|
|
||||||
{
|
|
||||||
var appHost = Path.Combine(
|
|
||||||
AppContext.BaseDirectory,
|
|
||||||
OperatingSystem.IsWindows() ? entry + ".exe" : entry!);
|
|
||||||
if (File.Exists(appHost)) return appHost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeChannel(string channel)
|
|
||||||
=> string.IsNullOrWhiteSpace(channel) ? "prod" : channel.Trim();
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
using Deploymentcenter.Client;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>An activated license: the key belonging to this machine plus the last result.</summary>
|
|
||||||
public sealed class LicenseSession
|
|
||||||
{
|
|
||||||
public LicenseSession(LicenseClient client, string licenseKey, LicenseValidationResult result)
|
|
||||||
{
|
|
||||||
Client = client;
|
|
||||||
LicenseKey = licenseKey;
|
|
||||||
LastResult = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LicenseClient Client { get; }
|
|
||||||
public string LicenseKey { get; }
|
|
||||||
public LicenseValidationResult LastResult { get; internal set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Ergebnis der Pruefung des zwischengespeicherten Schluessels.</summary>
|
|
||||||
/// <param name="Session">Gesetzt, wenn eine nutzbare Lizenz vorliegt.</param>
|
|
||||||
/// <param name="LastResult">Letzte Serverantwort — null, wenn gar kein Schluessel hinterlegt war.</param>
|
|
||||||
public readonly record struct CachedLicenseCheck(LicenseSession? Session, LicenseValidationResult? LastResult)
|
|
||||||
{
|
|
||||||
public bool IsUsable => Session is not null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate).
|
|
||||||
/// <para>
|
|
||||||
/// Endpoint and product slug are deliberately compiled in (see <see cref="DcConfig"/>):
|
|
||||||
/// a configurable endpoint would let anyone point the app at a fake license server.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Enthaelt bewusst keinen Dialog: die interaktive Aktivierung liegt beim Host
|
|
||||||
/// (Avalonia-Fenster), der Headless-Modus bezieht den Schluessel aus der
|
|
||||||
/// Umgebungsvariable oder dem verschluesselten Cache.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static class LicenseGuard
|
|
||||||
{
|
|
||||||
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
|
||||||
public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12);
|
|
||||||
|
|
||||||
public static LicenseClient CreateClient() => new();
|
|
||||||
|
|
||||||
/// <summary>Hardware ID v2 of this machine — shown in the dialog and needed for support.</summary>
|
|
||||||
public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(DcConfig.ProductSlug);
|
|
||||||
|
|
||||||
/// <summary>Ablageort des verschluesselten Lizenz-Caches.</summary>
|
|
||||||
public static string StorageDirectory => LicenseConfig.GetStorageDirectory(DcConfig.ProductSlug);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Prueft den Schluessel der letzten erfolgreichen Aktivierung aus dem verschluesselten
|
|
||||||
/// Cache. Der Host entscheidet, was bei einem negativen Ergebnis passiert — Dialog
|
|
||||||
/// anzeigen (GUI) oder mit Fehlercode beenden (headless).
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<CachedLicenseCheck> TryUseCachedAsync(LicenseClient? client = null)
|
|
||||||
{
|
|
||||||
client ??= CreateClient();
|
|
||||||
var hardware = GetHardwareInfo();
|
|
||||||
|
|
||||||
// The client takes the key as a parameter on every call; the key of the last
|
|
||||||
// successful activation lives in the encrypted local cache.
|
|
||||||
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
|
||||||
if (string.IsNullOrWhiteSpace(cachedKey))
|
|
||||||
{
|
|
||||||
return new CachedLicenseCheck(null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await client.ValidateAsync(DcConfig.ProductSlug, cachedKey!, DcConfig.BaseUrl);
|
|
||||||
if (result.IsValid)
|
|
||||||
{
|
|
||||||
Log.Information("🔑 Lizenz geprüft: {Status}{Cached} (HWID {Hwid}, Quelle {Source})",
|
|
||||||
result.Status, result.IsCached ? " — aus Offline-Cache" : "",
|
|
||||||
hardware.HardwareId, hardware.HwidSource);
|
|
||||||
WarnIfGraceRunningOut(result);
|
|
||||||
return new CachedLicenseCheck(new LicenseSession(client, cachedKey!, result), result);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Warning("🔑 Gespeicherter Lizenzschlüssel nicht nutzbar ({Status}): {Message}",
|
|
||||||
result.Status, result.Message);
|
|
||||||
return new CachedLicenseCheck(null, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Aktiviert einen neu eingegebenen Schluessel.</summary>
|
|
||||||
public static async Task<(LicenseSession? Session, LicenseValidationResult Result)> ActivateAsync(
|
|
||||||
LicenseClient client, string licenseKey)
|
|
||||||
{
|
|
||||||
var key = licenseKey.Trim().ToUpperInvariant();
|
|
||||||
var result = await client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl);
|
|
||||||
return result.IsValid
|
|
||||||
? (new LicenseSession(client, key, result), result)
|
|
||||||
: (null, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app keeps
|
|
||||||
/// running; only a definitive negative triggers <paramref name="onUnusable"/> — a server
|
|
||||||
/// outage must not.
|
|
||||||
/// </summary>
|
|
||||||
public static IDisposable StartPeriodicRevalidation(
|
|
||||||
LicenseSession session, Action<LicenseValidationResult> onUnusable)
|
|
||||||
{
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
using var timer = new PeriodicTimer(RevalidationInterval);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (await timer.WaitForNextTickAsync(cts.Token))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await session.Client.ValidateAsync(
|
|
||||||
DcConfig.ProductSlug, session.LicenseKey, DcConfig.BaseUrl);
|
|
||||||
session.LastResult = result;
|
|
||||||
|
|
||||||
if (result.IsValid)
|
|
||||||
{
|
|
||||||
if (result.IsCached)
|
|
||||||
{
|
|
||||||
Log.Warning("Lizenzserver nicht erreichbar — Prüfung erfolgte aus dem Offline-Cache.");
|
|
||||||
}
|
|
||||||
WarnIfGraceRunningOut(result);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.IsTransient)
|
|
||||||
{
|
|
||||||
// server_unavailable / cache_expired: no verdict, only a failed connection.
|
|
||||||
// A running installation must not be shut down for that — but an exhausted
|
|
||||||
// grace period is worth more than a warning: the next start will stop at
|
|
||||||
// the license dialog.
|
|
||||||
if (result.Status.Equals("cache_expired", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
Log.Error("Offline-Gnadenfrist abgelaufen und das Deployment Center ist nicht erreichbar. " +
|
|
||||||
"Diese Sitzung läuft weiter, der nächste Start verlangt aber eine Online-Prüfung: {Message}",
|
|
||||||
result.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Warning("Lizenz-Revalidierung vorläufig fehlgeschlagen ({Status}): {Message} — wird erneut versucht.",
|
|
||||||
result.Status, result.Message);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Fatal("Lizenzprüfung fehlgeschlagen ({Status}): {Message} — Anwendung wird beendet.",
|
|
||||||
result.Status, result.Message);
|
|
||||||
onUnusable(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Never kill the app from an unexpected exception here.
|
|
||||||
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Regulaeres Beenden.
|
|
||||||
}
|
|
||||||
}, cts.Token);
|
|
||||||
|
|
||||||
return new Stopper(cts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Since 2.1 the offline grace period is really bounded (cache_ttl_hours, default 168 h).
|
|
||||||
/// A machine that is offline on purpose should learn that before it runs out, not after.
|
|
||||||
/// </summary>
|
|
||||||
private static void WarnIfGraceRunningOut(LicenseValidationResult result)
|
|
||||||
{
|
|
||||||
if (result.CacheExpiresAt is not { } expiresAt || expiresAt <= 0) return;
|
|
||||||
|
|
||||||
var remaining = DateTimeOffset.FromUnixTimeSeconds(expiresAt) - DateTimeOffset.UtcNow;
|
|
||||||
if (remaining > TimeSpan.FromHours(48)) return;
|
|
||||||
|
|
||||||
Log.Warning("🔑 Offline-Gnadenfrist endet in {Hours:F0} h ({Until:yyyy-MM-dd HH:mm} UTC) — " +
|
|
||||||
"bis dahin muss das Deployment Center einmal erreichbar sein.",
|
|
||||||
Math.Max(0, remaining.TotalHours), DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class Stopper(CancellationTokenSource cts) : IDisposable
|
|
||||||
{
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
try { cts.Cancel(); } catch { /* bereits beendet */ }
|
|
||||||
cts.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<RootNamespace>Predictalytics.Hosting</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Bewusst der normale SDK statt Microsoft.NET.Sdk.Web: das hier ist eine
|
|
||||||
Bibliothek, kein Web-Projekt. Der FrameworkReference liefert WebApplication
|
|
||||||
und Kestrel, ohne die Static-Web-Assets-Maschinerie des Web-SDK
|
|
||||||
mitzuschleppen (die mit dem wwwroot-Content der Hosts kollidiert). -->
|
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Serilog" />
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.File" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
|
||||||
<PackageReference Include="Serilog.Formatting.Compact" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
|
|
||||||
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
|
||||||
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
|
||||||
<!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem Predictalytics-Checkout liegen.
|
|
||||||
Loest Watchdog + LicenseLabrador ab (Lizenz, UpdateService, Fehler-Stream, Bugtracker). -->
|
|
||||||
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine
|
|
||||||
fehlende Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
|
|
||||||
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
|
|
||||||
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')"
|
|
||||||
Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem Predictalytics-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
<!-- Erzeugt Predictalytics.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal)
|
|
||||||
zur Uebersetzungszeit aus <Version> und dem Git-Stand. -->
|
|
||||||
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Laufzeit-Konfiguration der Anwendung.
|
|
||||||
/// <para>
|
|
||||||
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
|
|
||||||
/// Basisbibliothek). Die Avalonia-Shell nutzt sie fuer Beschriftungen und Hilfetexte
|
|
||||||
/// der Einstellungsansicht.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PredictalyticsOptions
|
|
||||||
{
|
|
||||||
private const string FileName = "settings.json";
|
|
||||||
|
|
||||||
// ─── Webserver ───
|
|
||||||
|
|
||||||
[Category("Webserver")]
|
|
||||||
[DisplayName("Port")]
|
|
||||||
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
|
||||||
[DefaultValue(5000)]
|
|
||||||
public int WebserverPort { get; set; } = 5000;
|
|
||||||
|
|
||||||
[Category("Webserver")]
|
|
||||||
[DisplayName("Bind-Adresse")]
|
|
||||||
[Description("Netzwerkschnittstelle, auf der Kestrel lauscht. 'localhost' = nur lokal (Standard), " +
|
|
||||||
"'0.0.0.0' = von außen erreichbar. Bei 0.0.0.0 muss die API abgesichert werden!")]
|
|
||||||
[DefaultValue("localhost")]
|
|
||||||
public string WebserverHost { get; set; } = "localhost";
|
|
||||||
|
|
||||||
[Category("Webserver")]
|
|
||||||
[DisplayName("Database Debug")]
|
|
||||||
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
|
|
||||||
[DefaultValue(false)]
|
|
||||||
public bool DbConnectionDebug { get; set; } = false;
|
|
||||||
|
|
||||||
[Category("Webserver")]
|
|
||||||
[DisplayName("API-Token (Steuerbefehle)")]
|
|
||||||
[Description("Schützt die schreibenden Endpunkte (Jobs, Trader-Steuerung, Dev). Aufrufer müssen dann " +
|
|
||||||
"den Header X-Predictalytics-Key mitschicken. Bei einer Bind-Adresse außerhalb von localhost " +
|
|
||||||
"ist der Token Pflicht — ohne ihn werden die Steuerbefehle gar nicht erst bereitgestellt.")]
|
|
||||||
[PasswordPropertyText(true)]
|
|
||||||
public string ApiControlToken { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ob die Bind-Adresse den Dienst ueber das Netz erreichbar macht. Bei localhost
|
|
||||||
/// uebernimmt das Betriebssystem die Absicherung, sonst nicht mehr.
|
|
||||||
/// </summary>
|
|
||||||
[Browsable(false)]
|
|
||||||
[JsonIgnore]
|
|
||||||
public bool IsPubliclyBound
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var host = (WebserverHost ?? "").Trim();
|
|
||||||
return host.Length > 0
|
|
||||||
&& !host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& host != "127.0.0.1"
|
|
||||||
&& host != "::1";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Egress ───
|
|
||||||
|
|
||||||
private string _egressChannelsText = "";
|
|
||||||
|
|
||||||
[Category("Egress (Proxy/IP)")]
|
|
||||||
[DisplayName("Egress Channels")]
|
|
||||||
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). " +
|
|
||||||
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
|
|
||||||
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
|
||||||
|
|
||||||
// ─── Deployment Center ───
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Server URL")]
|
|
||||||
[Description("Basis-URL des Deployment Centers. Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen.")]
|
|
||||||
[ReadOnly(true)]
|
|
||||||
[JsonIgnore]
|
|
||||||
public string DcServerUrl => DcConfig.BaseUrl;
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("API Token")]
|
|
||||||
[Description("Token des Deployment Centers (Authorization: Bearer). Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting. Ohne Token werden weder Heartbeats noch Fehler gemeldet.")]
|
|
||||||
[PasswordPropertyText(true)]
|
|
||||||
public string DcToken { get; set; } = "";
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Heartbeat aktiv")]
|
|
||||||
[Description("Sendet periodische Heartbeats an den Watchdog des Deployment Centers (Dead-Man's-Switch). Benötigt ein Token.")]
|
|
||||||
[DefaultValue(true)]
|
|
||||||
public bool DcHeartbeatEnabled { get; set; } = true;
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Monitor Source")]
|
|
||||||
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
|
|
||||||
[DefaultValue("Predictalytics")]
|
|
||||||
public string DcSource { get; set; } = "Predictalytics";
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Monitor Instance")]
|
|
||||||
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
|
|
||||||
[DefaultValue("default")]
|
|
||||||
public string DcInstance { get; set; } = "default";
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Heartbeat-Intervall (Sekunden)")]
|
|
||||||
[Description("Sende-Takt der Heartbeats. Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'.")]
|
|
||||||
[DefaultValue(60)]
|
|
||||||
public int DcHeartbeatIntervalSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Fehler melden")]
|
|
||||||
[Description("Meldet Laufzeitfehler (Error/Fatal) an den Fehler-Stream des Deployment Centers. Gleiche Fehler werden dort gruppiert und hochgezählt.")]
|
|
||||||
[DefaultValue(true)]
|
|
||||||
public bool DcErrorReportingEnabled { get; set; } = true;
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Update-Prüfung beim Start")]
|
|
||||||
[Description("Prüft beim Start, ob im gewählten Kanal ein neueres Release vorliegt. Installiert wird nichts automatisch.")]
|
|
||||||
[DefaultValue(true)]
|
|
||||||
public bool DcUpdateCheckEnabled { get; set; } = true;
|
|
||||||
|
|
||||||
[Category("Deployment Center")]
|
|
||||||
[DisplayName("Update-Kanal")]
|
|
||||||
[Description("prod, beta oder dev.")]
|
|
||||||
[DefaultValue("prod")]
|
|
||||||
public string DcUpdateChannel { get; set; } = "prod";
|
|
||||||
|
|
||||||
// ─── Database ───
|
|
||||||
|
|
||||||
private string _dbServer = "localhost";
|
|
||||||
private string _dbName = "";
|
|
||||||
private string _dbUser = "";
|
|
||||||
private string _dbPassword = "";
|
|
||||||
|
|
||||||
[Category("Database")]
|
|
||||||
[DisplayName("Server")]
|
|
||||||
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
|
|
||||||
|
|
||||||
[Category("Database")]
|
|
||||||
[DisplayName("Database")]
|
|
||||||
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
|
|
||||||
|
|
||||||
[Category("Database")]
|
|
||||||
[DisplayName("User")]
|
|
||||||
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
|
|
||||||
|
|
||||||
[Category("Database")]
|
|
||||||
[DisplayName("Password")]
|
|
||||||
[PasswordPropertyText(true)]
|
|
||||||
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
|
|
||||||
|
|
||||||
[Category("Database")]
|
|
||||||
[DisplayName("SSL Mode")]
|
|
||||||
[Description("Verschlüsselung der MySQL-Verbindung. 'None' = unverschlüsselt (bisheriges Verhalten), " +
|
|
||||||
"'Preferred' = verschlüsselt wenn der Server es unterstützt. Bei entfernten Servern 'Preferred' empfohlen.")]
|
|
||||||
[DefaultValue(MySqlConnector.MySqlSslMode.None)]
|
|
||||||
public MySqlConnector.MySqlSslMode DbSslMode { get; set; } = MySqlConnector.MySqlSslMode.None;
|
|
||||||
|
|
||||||
[Browsable(false)]
|
|
||||||
[JsonIgnore]
|
|
||||||
public string ConnectionString
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var builder = new MySqlConnector.MySqlConnectionStringBuilder
|
|
||||||
{
|
|
||||||
Server = DbServer?.Trim(),
|
|
||||||
Database = DbName?.Trim(),
|
|
||||||
UserID = DbUser?.Trim(),
|
|
||||||
Password = DbPassword?.Trim(),
|
|
||||||
AllowPublicKeyRetrieval = true,
|
|
||||||
SslMode = DbSslMode,
|
|
||||||
Pooling = true,
|
|
||||||
MinimumPoolSize = 0,
|
|
||||||
MaximumPoolSize = 100
|
|
||||||
};
|
|
||||||
return builder.ConnectionString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Kestrel-Bind-URL aus Host und Port.</summary>
|
|
||||||
[Browsable(false)]
|
|
||||||
[JsonIgnore]
|
|
||||||
public string WebserverUrl =>
|
|
||||||
$"http://{(string.IsNullOrWhiteSpace(WebserverHost) ? "localhost" : WebserverHost.Trim())}:{WebserverPort}";
|
|
||||||
|
|
||||||
// ─── Persistenz ───
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ablageort der Einstellungen: %APPDATA%\Predictalytics unter Windows,
|
|
||||||
/// ~/.config/Predictalytics unter Linux. Bewusst nicht neben der Programmdatei —
|
|
||||||
/// deren Verzeichnis ist unter Linux ueblicherweise nicht beschreibbar.
|
|
||||||
/// </summary>
|
|
||||||
public static string SettingsDirectory => Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"Predictalytics");
|
|
||||||
|
|
||||||
public static string SettingsFilePath => Path.Combine(SettingsDirectory, FileName);
|
|
||||||
|
|
||||||
/// <summary>Alter Ablageort neben der Programmdatei (bis einschliesslich der WinForms-Fassung).</summary>
|
|
||||||
private static string LegacySettingsFilePath => Path.Combine(AppContext.BaseDirectory, FileName);
|
|
||||||
|
|
||||||
public static PredictalyticsOptions Load()
|
|
||||||
{
|
|
||||||
// Neuer Ort hat Vorrang; sonst einmalig aus dem alten uebernehmen, damit
|
|
||||||
// bestehende Zugangsdaten beim Umstieg nicht verloren gehen.
|
|
||||||
var path = File.Exists(SettingsFilePath) ? SettingsFilePath
|
|
||||||
: File.Exists(LegacySettingsFilePath) ? LegacySettingsFilePath
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (path == null)
|
|
||||||
{
|
|
||||||
var fresh = new PredictalyticsOptions();
|
|
||||||
fresh.Save();
|
|
||||||
return fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = JsonSerializer.Deserialize<PredictalyticsOptions>(File.ReadAllText(path))
|
|
||||||
?? new PredictalyticsOptions();
|
|
||||||
if (path == LegacySettingsFilePath) options.Save(); // Migration festschreiben
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return new PredictalyticsOptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Save()
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(SettingsDirectory);
|
|
||||||
File.WriteAllText(SettingsFilePath,
|
|
||||||
JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Laufzeitvorgaben, die vor allem anderen gelten muessen — unabhaengig davon,
|
|
||||||
/// ob die Anwendung mit Oberflaeche oder headless startet.
|
|
||||||
/// </summary>
|
|
||||||
public static class RuntimeSetup
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Legt die invariante Kultur fuer alle Threads fest.
|
|
||||||
/// <para>
|
|
||||||
/// Die Anwendung verarbeitet ausschliesslich Maschinendaten: Preise, Groessen und
|
|
||||||
/// Zeitstempel der Plattform-APIs sowie SQL. Wuerde die Systemlocale durchschlagen,
|
|
||||||
/// haette derselbe Datensatz auf einem deutschen und einem englischen System
|
|
||||||
/// unterschiedliche Werte — unter Linux faellt das besonders leicht auf, weil dort
|
|
||||||
/// die Locale ueblicherweise ausdruecklich gesetzt wird.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Bewusst nicht ueber <c>InvariantGlobalization</c> im Projekt: das aendert
|
|
||||||
/// zusaetzlich die Semantik von Zeichenkettenvergleichen und waere ein groesserer
|
|
||||||
/// Eingriff als noetig.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static void UseInvariantCulture()
|
|
||||||
{
|
|
||||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
|
||||||
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
|
|
||||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
|
||||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -231,16 +231,13 @@ public class MarketRepository : IMarketRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Kuerzt die Textfelder eines Events auf die im Modell konfigurierten Laengen.
|
/// Caps every length-constrained Event string to its column width.
|
||||||
/// Ohne das bricht der gesamte Speichervorgang mit
|
/// Must cover ALL such fields: MySQL rejects the whole batch with
|
||||||
/// "Data too long for column 'Description'" ab und der Marktabgleich endet
|
/// "Data too long for column ..." if a single one overflows, so one long
|
||||||
/// mitten im Durchlauf — Polymarket liefert Beschreibungen weit ueber 4096 Zeichen.
|
/// description aborts the entire market sync — not just that one event.
|
||||||
/// <para>
|
/// Keep in sync with the HasMaxLength calls in AppDbContext.
|
||||||
/// Muss <b>jedes</b> laengenbeschraenkte Feld abdecken und mit den HasMaxLength-Aufrufen
|
|
||||||
/// in AppDbContext synchron bleiben: ein einziges zu langes Feld kippt den ganzen Batch.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void TruncateEventStrings(Event ev)
|
private void TruncateEventStrings(Event ev)
|
||||||
{
|
{
|
||||||
ev.Slug = StringHelper.Truncate(ev.Slug, 512) ?? "";
|
ev.Slug = StringHelper.Truncate(ev.Slug, 512) ?? "";
|
||||||
ev.Title = StringHelper.Truncate(ev.Title, 1024) ?? "";
|
ev.Title = StringHelper.Truncate(ev.Title, 1024) ?? "";
|
||||||
|
|||||||
@@ -71,10 +71,7 @@ public class TradeRepository : ITradeRepository
|
|||||||
foreach (var chunk in tradeList.Chunk(500))
|
foreach (var chunk in tradeList.Chunk(500))
|
||||||
{
|
{
|
||||||
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount, UsdcSize, OutcomeIndex) VALUES ");
|
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount, UsdcSize, OutcomeIndex) VALUES ");
|
||||||
// object? statt object: TransactionHash, MarketOutcomeId, DbMarketId,
|
var parameters = new List<object>();
|
||||||
// AggregatedCount, UsdcSize und OutcomeIndex sind nullable und sollen als
|
|
||||||
// SQL-NULL durchgereicht werden.
|
|
||||||
var parameters = new List<object?>();
|
|
||||||
|
|
||||||
for (int i = 0; i < chunk.Length; i++)
|
for (int i = 0; i < chunk.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -114,7 +111,7 @@ public class TradeRepository : ITradeRepository
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray()!, ct);
|
var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct);
|
||||||
_logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted);
|
_logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ public static class DependencyInjection
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies pending EF Core migrations and seeds default platform rows.
|
/// Applies pending EF Core migrations and seeds default platform rows.
|
||||||
/// Requires the target database to already be "stamped" with the InitialBaseline
|
/// Requires the target database to already be "stamped" with the InitialBaseline
|
||||||
/// migration in __EFMigrationsHistory (see docs/archiv/UMSETZUNGSPLAN.md, B1) if it was
|
/// migration in __EFMigrationsHistory (see UMSETZUNGSPLAN.md, section B1) if it was
|
||||||
/// previously created via the old EnsureCreated + manual ALTER approach.
|
/// previously created via the old EnsureCreated + manual ALTER approach.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
|
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace Predictalytics.Infrastructure.Helpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses timestamp strings from platform APIs as UTC.
|
|
||||||
/// <para>
|
|
||||||
/// The platforms deliver ISO-8601 strings; the domain stores UTC throughout — every call
|
|
||||||
/// site falls back to <see cref="DateTime.UtcNow"/>, and the neighbouring DbCreatedAt is
|
|
||||||
/// always UTC. A plain <c>DateTime.TryParse</c> breaks that in two ways: it honours the
|
|
||||||
/// machine's locale, and it converts a "Z"-suffixed timestamp to <em>local</em> time. The
|
|
||||||
/// same API payload would therefore land in the database differently on a Berlin
|
|
||||||
/// workstation than on a UTC server.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static class DateParsing
|
|
||||||
{
|
|
||||||
private const DateTimeStyles UtcStyles =
|
|
||||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses an API timestamp as UTC. Returns null when absent or unparseable.
|
|
||||||
/// Timestamps without a zone designator are treated as UTC.
|
|
||||||
/// </summary>
|
|
||||||
public static DateTime? ParseUtc(string? value) =>
|
|
||||||
DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var parsed)
|
|
||||||
? parsed
|
|
||||||
: null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses an API timestamp as UTC, falling back to the current UTC time.
|
|
||||||
/// </summary>
|
|
||||||
public static DateTime ParseUtcOrNow(string? value) => ParseUtc(value) ?? DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
using Serilog.Core;
|
using Serilog.Core;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
|
|
||||||
namespace Predictalytics.Infrastructure.Logging;
|
namespace Predictalytics.Infrastructure.Logging;
|
||||||
@@ -7,11 +7,11 @@ namespace Predictalytics.Infrastructure.Logging;
|
|||||||
/// Custom Serilog sink that delegates log writes to a provided action.
|
/// Custom Serilog sink that delegates log writes to a provided action.
|
||||||
/// The action is responsible for marshaling to the correct thread (e.g. UI thread).
|
/// The action is responsible for marshaling to the correct thread (e.g. UI thread).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DelegateSink : ILogEventSink
|
public class RichTextBoxSink : ILogEventSink
|
||||||
{
|
{
|
||||||
private readonly Action<string, LogEventLevel> _writeAction;
|
private readonly Action<string, LogEventLevel> _writeAction;
|
||||||
|
|
||||||
public DelegateSink(Action<string, LogEventLevel> writeAction)
|
public RichTextBoxSink(Action<string, LogEventLevel> writeAction)
|
||||||
{
|
{
|
||||||
_writeAction = writeAction;
|
_writeAction = writeAction;
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>Predictalytics.Infrastructure</RootNamespace>
|
<RootNamespace>Predictalytics.Infrastructure</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||||
<PackageReference Include="Serilog" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Predictalytics.Domain.Entities;
|
using Predictalytics.Domain.Entities;
|
||||||
using Predictalytics.Domain.Enums;
|
using Predictalytics.Domain.Enums;
|
||||||
using Predictalytics.Domain.Interfaces;
|
using Predictalytics.Domain.Interfaces;
|
||||||
using Predictalytics.Infrastructure.Helpers;
|
|
||||||
using Predictalytics.Infrastructure.Logging;
|
using Predictalytics.Infrastructure.Logging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -237,7 +235,7 @@ public class LimitlessProvider : IPlatformProvider
|
|||||||
Price = (decimal)(e.Price ?? 0),
|
Price = (decimal)(e.Price ?? 0),
|
||||||
Size = (decimal)(e.Size ?? 0),
|
Size = (decimal)(e.Size ?? 0),
|
||||||
Amount = (decimal)(e.Price * e.Size ?? 0),
|
Amount = (decimal)(e.Price * e.Size ?? 0),
|
||||||
ExecutedAt = DateParsing.ParseUtcOrNow(e.CreatedAt),
|
ExecutedAt = DateTime.TryParse(e.CreatedAt, out var dt) ? dt : DateTime.UtcNow,
|
||||||
TransactionHash = e.TxHash,
|
TransactionHash = e.TxHash,
|
||||||
TransientWallet = wallet,
|
TransientWallet = wallet,
|
||||||
};
|
};
|
||||||
@@ -267,12 +265,11 @@ public class LimitlessProvider : IPlatformProvider
|
|||||||
Category = catMap.Category,
|
Category = catMap.Category,
|
||||||
Subcategory = catMap.Subcategory,
|
Subcategory = catMap.Subcategory,
|
||||||
ImageUrl = raw.ImageUrl ?? "",
|
ImageUrl = raw.ImageUrl ?? "",
|
||||||
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""),
|
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
|
||||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var vol) ? vol : 0,
|
|
||||||
Liquidity = (decimal)(raw.Liquidity ?? 0),
|
Liquidity = (decimal)(raw.Liquidity ?? 0),
|
||||||
EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799)
|
EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799)
|
||||||
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
|
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
|
||||||
: DateParsing.ParseUtc(raw.ExpirationDate),
|
: (DateTime.TryParse(raw.ExpirationDate, out var ed) ? ed : null),
|
||||||
IsResolved = raw.Closed,
|
IsResolved = raw.Closed,
|
||||||
LastUpdatedAt = DateTime.UtcNow
|
LastUpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,13 +18,6 @@ public class OpenRouterApiClient : IOpenRouterApiClient
|
|||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
private readonly ILogger<OpenRouterApiClient> _logger;
|
private readonly ILogger<OpenRouterApiClient> _logger;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ohne Schluessel wird gar nicht erst gesendet. Sonst quittiert OpenRouter jeden
|
|
||||||
/// Aufruf mit 401, das landet als Error im Fehler-Stream des Deployment Centers und
|
|
||||||
/// der Aufrufer versucht anschliessend, die Fehlermeldung als JSON zu lesen.
|
|
||||||
/// </summary>
|
|
||||||
private readonly bool _configured;
|
|
||||||
|
|
||||||
public OpenRouterApiClient(HttpClient httpClient, IConfiguration config, ILogger<OpenRouterApiClient> logger)
|
public OpenRouterApiClient(HttpClient httpClient, IConfiguration config, ILogger<OpenRouterApiClient> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
@@ -35,17 +28,10 @@ public class OpenRouterApiClient : IOpenRouterApiClient
|
|||||||
var apiKey = _config["OpenRouter:ApiKey"];
|
var apiKey = _config["OpenRouter:ApiKey"];
|
||||||
|
|
||||||
_httpClient.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/");
|
_httpClient.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/");
|
||||||
_configured = !string.IsNullOrWhiteSpace(apiKey);
|
if (!string.IsNullOrEmpty(apiKey))
|
||||||
if (_configured)
|
|
||||||
{
|
{
|
||||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// Warning, nicht Error: eine fehlende KI-Anbindung ist ein Konfigurationszustand,
|
|
||||||
// kein Stoerfall - die uebrige Analyse laeuft vollstaendig ohne sie.
|
|
||||||
_logger.LogWarning("OpenRouter ist nicht konfiguriert (OpenRouter:ApiKey leer) - die KI-Strategieanalyse bleibt aus. Schluessel ueber die Umgebungsvariable OpenRouter__ApiKey setzen.");
|
|
||||||
}
|
|
||||||
// OpenRouter recommends adding a referer and title for ranking
|
// OpenRouter recommends adding a referer and title for ranking
|
||||||
_httpClient.DefaultRequestHeaders.Add("HTTP-Referer", "http://localhost");
|
_httpClient.DefaultRequestHeaders.Add("HTTP-Referer", "http://localhost");
|
||||||
_httpClient.DefaultRequestHeaders.Add("X-Title", "Predictalytics");
|
_httpClient.DefaultRequestHeaders.Add("X-Title", "Predictalytics");
|
||||||
@@ -53,8 +39,6 @@ public class OpenRouterApiClient : IOpenRouterApiClient
|
|||||||
|
|
||||||
public async Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default)
|
public async Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!_configured) return IOpenRouterApiClient.NotConfigured;
|
|
||||||
|
|
||||||
var model = useManualModel
|
var model = useManualModel
|
||||||
? _config["OpenRouter:ManualAnalysisModel"] ?? "anthropic/claude-3-opus"
|
? _config["OpenRouter:ManualAnalysisModel"] ?? "anthropic/claude-3-opus"
|
||||||
: _config["OpenRouter:DefaultModel"] ?? "google/gemini-flash-1.5";
|
: _config["OpenRouter:DefaultModel"] ?? "google/gemini-flash-1.5";
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System.Text.Json;
|
|||||||
using Predictalytics.Domain.Entities;
|
using Predictalytics.Domain.Entities;
|
||||||
using Predictalytics.Domain.Enums;
|
using Predictalytics.Domain.Enums;
|
||||||
using Predictalytics.Domain.Interfaces;
|
using Predictalytics.Domain.Interfaces;
|
||||||
using Predictalytics.Infrastructure.Helpers;
|
|
||||||
using Predictalytics.Infrastructure.Logging;
|
using Predictalytics.Infrastructure.Logging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -217,9 +216,9 @@ public class PolymarketProvider : IPlatformProvider
|
|||||||
Title = rawEv.Title,
|
Title = rawEv.Title,
|
||||||
Description = rawEv.Description,
|
Description = rawEv.Description,
|
||||||
ImageUrl = rawEv.Image,
|
ImageUrl = rawEv.Image,
|
||||||
StartDate = DateParsing.ParseUtc(rawEv.StartDate),
|
StartDate = DateTime.TryParse(rawEv.StartDate, out var esd) ? esd : null,
|
||||||
EndDate = DateParsing.ParseUtc(rawEv.EndDate),
|
EndDate = DateTime.TryParse(rawEv.EndDate, out var eed) ? eed : null,
|
||||||
CreatedAt = DateParsing.ParseUtcOrNow(rawEv.CreatedAt),
|
CreatedAt = DateTime.TryParse(rawEv.CreatedAt, out var ecd) ? ecd : DateTime.UtcNow,
|
||||||
DbCreatedAt = DateTime.UtcNow,
|
DbCreatedAt = DateTime.UtcNow,
|
||||||
IsActive = rawEv.Active,
|
IsActive = rawEv.Active,
|
||||||
IsClosed = rawEv.Closed,
|
IsClosed = rawEv.Closed,
|
||||||
@@ -313,9 +312,9 @@ public class PolymarketProvider : IPlatformProvider
|
|||||||
Title = rawEvent.Title,
|
Title = rawEvent.Title,
|
||||||
Description = rawEvent.Description,
|
Description = rawEvent.Description,
|
||||||
ImageUrl = rawEvent.Image,
|
ImageUrl = rawEvent.Image,
|
||||||
StartDate = DateParsing.ParseUtc(rawEvent.StartDate),
|
StartDate = DateTime.TryParse(rawEvent.StartDate, out var sd) ? sd : null,
|
||||||
EndDate = DateParsing.ParseUtc(rawEvent.EndDate),
|
EndDate = DateTime.TryParse(rawEvent.EndDate, out var ed) ? ed : null,
|
||||||
CreatedAt = DateParsing.ParseUtcOrNow(rawEvent.CreatedAt),
|
CreatedAt = DateTime.TryParse(rawEvent.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
|
||||||
DbCreatedAt = DateTime.UtcNow,
|
DbCreatedAt = DateTime.UtcNow,
|
||||||
IsActive = rawEvent.Active,
|
IsActive = rawEvent.Active,
|
||||||
IsClosed = rawEvent.Closed,
|
IsClosed = rawEvent.Closed,
|
||||||
@@ -354,20 +353,20 @@ public class PolymarketProvider : IPlatformProvider
|
|||||||
MarketSlug = raw.Slug,
|
MarketSlug = raw.Slug,
|
||||||
Description = raw.Description,
|
Description = raw.Description,
|
||||||
ImageUrl = raw.Image,
|
ImageUrl = raw.Image,
|
||||||
Question = raw.Question ?? "",
|
Question = raw.Question,
|
||||||
Category = catMap.Category,
|
Category = catMap.Category,
|
||||||
Subcategory = catMap.Subcategory,
|
Subcategory = catMap.Subcategory,
|
||||||
Volume = (decimal)raw.Volume,
|
Volume = (decimal)raw.Volume,
|
||||||
Volume24h = (decimal)raw.Volume24hr,
|
Volume24h = (decimal)raw.Volume24hr,
|
||||||
Liquidity = (decimal)raw.Liquidity,
|
Liquidity = (decimal)raw.Liquidity,
|
||||||
StartDate = DateParsing.ParseUtc(raw.StartDate),
|
StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null,
|
||||||
EndDate = DateParsing.ParseUtc(raw.EndDate ?? raw.EndDateIso),
|
EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
|
||||||
CreatedAt = DateParsing.ParseUtcOrNow(raw.CreatedAt),
|
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
|
||||||
DbCreatedAt = DateTime.UtcNow,
|
DbCreatedAt = DateTime.UtcNow,
|
||||||
LastUpdatedAt = DateTime.UtcNow,
|
LastUpdatedAt = DateTime.UtcNow,
|
||||||
FeeRateBps = (decimal)(raw.TakerFee * 10000),
|
FeeRateBps = (decimal)(raw.TakerFee * 10000),
|
||||||
IsNegRisk = raw.NegRisk,
|
IsNegRisk = raw.NegRisk,
|
||||||
ClosedAt = DateParsing.ParseUtc(raw.ClosedTime)
|
ClosedAt = DateTime.TryParse(raw.ClosedTime, out var mct) ? mct : null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse outcomes, prices, and token IDs from JSON strings
|
// Parse outcomes, prices, and token IDs from JSON strings
|
||||||
|
|||||||
@@ -73,10 +73,6 @@ public class EgressPoolService : IEgressPoolService, IDisposable
|
|||||||
handler.ConnectCallback = async (context, cancellationToken) =>
|
handler.ConnectCallback = async (context, cancellationToken) =>
|
||||||
{
|
{
|
||||||
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||||
// Linux ist hier strenger als Windows: die Adresse muss tatsaechlich
|
|
||||||
// auf einem Interface konfiguriert sein, sonst schlaegt Bind mit
|
|
||||||
// EADDRNOTAVAIL fehl. Fuer nicht-lokale Adressen braeuchte es
|
|
||||||
// IP_FREEBIND bzw. CAP_NET_ADMIN.
|
|
||||||
socket.Bind(new IPEndPoint(ip, 0));
|
socket.Bind(new IPEndPoint(ip, 0));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,13 +128,7 @@ public class FingerprintSnapshotService : IFingerprintSnapshotService
|
|||||||
var total = perfs.Sum(p => p.TotalVolume);
|
var total = perfs.Sum(p => p.TotalVolume);
|
||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
|
|
||||||
// Je Kategorie gibt es eine Zeile pro Unterkategorie (eindeutiger Index
|
var mix = perfs.ToDictionary(p => p.Category.ToString(), p => p.TotalVolume / total);
|
||||||
// TraderId+Category+Subcategory). Ein ToDictionary auf die Kategorie allein
|
|
||||||
// wirft deshalb bei jedem Trader, der in zwei Unterkategorien derselben
|
|
||||||
// Kategorie gehandelt hat ("Economy/Inflation" und "Economy/Jobs").
|
|
||||||
var mix = perfs
|
|
||||||
.GroupBy(p => p.Category.ToString())
|
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(p => p.TotalVolume) / total);
|
|
||||||
return JsonSerializer.Serialize(mix);
|
return JsonSerializer.Serialize(mix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<Application xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
x:Class="Predictalytics.Shell.App"
|
|
||||||
RequestedThemeVariant="Dark">
|
|
||||||
|
|
||||||
<Application.Styles>
|
|
||||||
<FluentTheme />
|
|
||||||
</Application.Styles>
|
|
||||||
|
|
||||||
</Application>
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Predictalytics.Hosting;
|
|
||||||
using Predictalytics.Shell.ViewModels;
|
|
||||||
using Predictalytics.Shell.Views;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Avalonia-Anwendungsobjekt.
|
|
||||||
/// <para>
|
|
||||||
/// Die Basisklasse wird voll qualifiziert angegeben: innerhalb von
|
|
||||||
/// <c>Predictalytics.*</c> loest der kurze Name <c>Application</c> auf den
|
|
||||||
/// Namespace <c>Predictalytics.Application</c> auf, nicht auf den Avalonia-Typ.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public partial class App : global::Avalonia.Application
|
|
||||||
{
|
|
||||||
private IDisposable? _licenseWatch;
|
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
|
||||||
{
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
||||||
{
|
|
||||||
// Das Lizenzfenster darf beim Schliessen nicht die Anwendung beenden —
|
|
||||||
// danach soll ja erst das Hauptfenster kommen.
|
|
||||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
|
||||||
desktop.Exit += (_, _) => _licenseWatch?.Dispose();
|
|
||||||
|
|
||||||
// Nicht awaiten: OnFrameworkInitializationCompleted muss zurueckkehren,
|
|
||||||
// damit die Nachrichtenschleife anlaufen kann.
|
|
||||||
Dispatcher.UIThread.Post(async () => await StartupAsync(desktop));
|
|
||||||
}
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
|
||||||
{
|
|
||||||
RuntimeSetup.UseInvariantCulture();
|
|
||||||
|
|
||||||
// Einstellungen zuerst: das Fehler-Reporting braucht das Token, bevor die
|
|
||||||
// Lizenzschranke laeuft — sonst taucht eine fehlgeschlagene Aktivierung
|
|
||||||
// nirgends im Fehler-Stream auf.
|
|
||||||
var options = PredictalyticsOptions.Load();
|
|
||||||
|
|
||||||
var viewModel = new MainWindowViewModel(options);
|
|
||||||
var window = new MainWindow(viewModel);
|
|
||||||
|
|
||||||
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier kommt nur der
|
|
||||||
// Terminal-Sink dazu, der selbst auf den UI-Thread marshallt.
|
|
||||||
LoggingSetup.Configure(viewModel.AppendLog);
|
|
||||||
|
|
||||||
// ─── Deployment Center: Fehler-Stream und globale Ausnahmebehandlung ───
|
|
||||||
DcErrorReporter.Configure(options.DcToken, options.DcErrorReportingEnabled);
|
|
||||||
DcErrorReporter.InstallGlobalHandlers();
|
|
||||||
InstallUiThreadHandler();
|
|
||||||
|
|
||||||
// Ohne das erschiene jede Aktivierung in der Lizenzliste als "1.0.0".
|
|
||||||
Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
|
|
||||||
|
|
||||||
LoggingSetup.LogStartupBanner();
|
|
||||||
|
|
||||||
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
|
|
||||||
var client = LicenseGuard.CreateClient();
|
|
||||||
var check = await LicenseGuard.TryUseCachedAsync(client);
|
|
||||||
var session = check.Session;
|
|
||||||
|
|
||||||
if (session is null)
|
|
||||||
{
|
|
||||||
var licenseWindow = new LicenseWindow(client, check.LastResult);
|
|
||||||
desktop.MainWindow = licenseWindow;
|
|
||||||
licenseWindow.Show();
|
|
||||||
|
|
||||||
session = await licenseWindow.Completion;
|
|
||||||
if (session is null)
|
|
||||||
{
|
|
||||||
desktop.Shutdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.License = session;
|
|
||||||
|
|
||||||
// ─── Hauptfenster ───
|
|
||||||
desktop.MainWindow = window;
|
|
||||||
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
|
||||||
window.Show();
|
|
||||||
|
|
||||||
viewModel.Start();
|
|
||||||
|
|
||||||
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
|
|
||||||
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(session, unusable =>
|
|
||||||
Dispatcher.UIThread.Post(async () =>
|
|
||||||
{
|
|
||||||
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
|
|
||||||
$"Die Lizenz ist nicht mehr gültig ({unusable.Status}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
|
|
||||||
desktop.Shutdown();
|
|
||||||
}));
|
|
||||||
|
|
||||||
desktop.Exit += (_, _) =>
|
|
||||||
{
|
|
||||||
Log.Information("Application shutting down.");
|
|
||||||
Log.CloseAndFlush();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gegenstueck zu <c>Application.ThreadException</c> in WinForms: eine im UI-Thread
|
|
||||||
/// durchgereichte Ausnahme wuerde den Prozess sonst spurlos beenden.
|
|
||||||
/// </summary>
|
|
||||||
private static void InstallUiThreadHandler()
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.UnhandledException += (_, e) =>
|
|
||||||
{
|
|
||||||
DcErrorReporter.ReportUiThreadException(e.Exception);
|
|
||||||
e.Handled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<!-- WinExe: unter Windows kein Konsolenfenster hinter der GUI. Unter Linux
|
|
||||||
ist WinExe gleichbedeutend mit Exe. Fuer den Headless-Modus wird unter
|
|
||||||
Windows die Elternkonsole angehaengt (siehe ConsoleAttach). -->
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<RootNamespace>Predictalytics.Shell</RootNamespace>
|
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Avalonia" />
|
|
||||||
<PackageReference Include="Avalonia.Desktop" />
|
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
|
||||||
<PackageReference Include="Serilog" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Api, Worker, Infrastructure und Deploymentcenter.Client kommen transitiv ueber Hosting. -->
|
|
||||||
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Die WebUI wird neben die Programmdatei kopiert, damit PredictalyticsHost sie
|
|
||||||
ueber AppContext.BaseDirectory findet. -->
|
|
||||||
<Content Include="..\Predictalytics.Api\wwwroot\**"
|
|
||||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
|
||||||
CopyToOutputDirectory="PreserveNewest"
|
|
||||||
CopyToPublishDirectory="PreserveNewest" />
|
|
||||||
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Predictalytics.Shell.Services;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell;
|
|
||||||
|
|
||||||
internal static class Program
|
|
||||||
{
|
|
||||||
[STAThread]
|
|
||||||
public static int Main(string[] args)
|
|
||||||
{
|
|
||||||
// Die Argumentauswertung steht bewusst vor jeder Avalonia-Initialisierung:
|
|
||||||
// auf einem Server ohne X11 wuerde der Prozess sonst beim Aufbau des
|
|
||||||
// Fenstersystems sterben, bevor die Schalter ueberhaupt greifen.
|
|
||||||
if (LicenseCli.IsLicenseCommand(args))
|
|
||||||
{
|
|
||||||
ConsoleAttach.TryAttachToParent();
|
|
||||||
return LicenseCli.RunAsync(args).GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
ConsoleAttach.TryAttachToParent();
|
|
||||||
return HeadlessRunner.RunAsync().GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Wird auch vom Avalonia-Designer verwendet.</summary>
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
|
||||||
AppBuilder.Configure<App>()
|
|
||||||
.UsePlatformDetect()
|
|
||||||
.WithInterFont()
|
|
||||||
.LogToTrace();
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Runtime.Versioning;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Konsolen-Vorbereitung fuer den Headless-Modus.
|
|
||||||
/// <para>
|
|
||||||
/// Das Projekt ist als WinExe gebaut, damit unter Windows kein Konsolenfenster
|
|
||||||
/// hinter der GUI steht. Im Headless-Modus fehlt dadurch aber die Ausgabe, wenn
|
|
||||||
/// aus einer Eingabeaufforderung gestartet wird — <see cref="TryAttachToParent"/>
|
|
||||||
/// haengt den Prozess an die Konsole des aufrufenden Prozesses an.
|
|
||||||
/// </para>
|
|
||||||
/// <para>Unter Linux ist WinExe gleichbedeutend mit Exe; dort entfaellt das Anhaengen.</para>
|
|
||||||
/// </summary>
|
|
||||||
internal static class ConsoleAttach
|
|
||||||
{
|
|
||||||
private const int AttachParentProcess = -1;
|
|
||||||
|
|
||||||
public static void TryAttachToParent()
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
try { AttachConsole(AttachParentProcess); }
|
|
||||||
catch { /* ohne Konsole bleiben die Logdateien */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Logausgabe enthaelt Rahmenzeichen und Emoji. Linux-Terminals sind
|
|
||||||
// ohnehin UTF-8, die Windows-Konsole laeuft ohne das hier auf der
|
|
||||||
// ANSI-Codepage und zeigt nur Fragezeichen.
|
|
||||||
try { Console.OutputEncoding = Encoding.UTF8; }
|
|
||||||
catch { /* keine Konsole angehaengt — irrelevant */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
private static extern bool AttachConsole(int dwProcessId);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
using System.Runtime.InteropServices;
|
|
||||||
using Predictalytics.Hosting;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Betrieb ohne Oberflaeche — fuer Linux-Server ohne Desktop-Session
|
|
||||||
/// (systemd, Container). Startet Webserver und Worker und laeuft, bis
|
|
||||||
/// SIGTERM oder Strg+C eintrifft.
|
|
||||||
/// </summary>
|
|
||||||
internal static class HeadlessRunner
|
|
||||||
{
|
|
||||||
/// <summary>Umgebungsvariable fuer die Aktivierung ohne Dialog.</summary>
|
|
||||||
private const string LicenseKeyVariable = "PREDICTALYTICS_LICENSE_KEY";
|
|
||||||
|
|
||||||
public const int ExitOk = 0;
|
|
||||||
public const int ExitNoLicense = 2;
|
|
||||||
public const int ExitFailed = 3;
|
|
||||||
|
|
||||||
public static async Task<int> RunAsync()
|
|
||||||
{
|
|
||||||
RuntimeSetup.UseInvariantCulture();
|
|
||||||
|
|
||||||
var options = PredictalyticsOptions.Load();
|
|
||||||
|
|
||||||
// Kein Terminal-Sink: Ausgabe geht auf die Konsole und in die Logdateien.
|
|
||||||
LoggingSetup.Configure();
|
|
||||||
|
|
||||||
DcErrorReporter.Configure(options.DcToken, options.DcErrorReportingEnabled);
|
|
||||||
DcErrorReporter.InstallGlobalHandlers();
|
|
||||||
Deploymentcenter.Client.LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
|
|
||||||
|
|
||||||
LoggingSetup.LogStartupBanner();
|
|
||||||
Log.Information("Headless-Modus. Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
|
|
||||||
|
|
||||||
DcHeartbeatService? heartbeat = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var session = await EnsureLicensedAsync();
|
|
||||||
if (session is null) return ExitNoLicense;
|
|
||||||
|
|
||||||
var host = new PredictalyticsHost(options);
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
using var shutdownSignals = RegisterShutdown(cts);
|
|
||||||
|
|
||||||
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(session, result =>
|
|
||||||
{
|
|
||||||
Log.Fatal("Lizenz nicht mehr gültig ({Status}): {Message} — Dienst wird beendet.",
|
|
||||||
result.Status, result.Message);
|
|
||||||
cts.Cancel();
|
|
||||||
});
|
|
||||||
|
|
||||||
heartbeat = StartHeartbeat(options, host);
|
|
||||||
|
|
||||||
await host.StartWebServerAsync();
|
|
||||||
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
|
|
||||||
|
|
||||||
// Laeuft, bis abgebrochen wird.
|
|
||||||
await host.StartWorkersAsync(cts.Token);
|
|
||||||
|
|
||||||
heartbeat?.NotifyStopping();
|
|
||||||
await host.StopWebServerAsync();
|
|
||||||
Log.Information("Dienst planmäßig beendet.");
|
|
||||||
return ExitOk;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
heartbeat?.NotifyStopping();
|
|
||||||
Log.Information("Dienst planmäßig beendet.");
|
|
||||||
return ExitOk;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Fatal(ex, "Headless-Betrieb abgebrochen.");
|
|
||||||
return ExitFailed;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
heartbeat?.Dispose();
|
|
||||||
await Log.CloseAndFlushAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
|
|
||||||
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<LicenseSession?> EnsureLicensedAsync()
|
|
||||||
{
|
|
||||||
var client = LicenseGuard.CreateClient();
|
|
||||||
|
|
||||||
var check = await LicenseGuard.TryUseCachedAsync(client);
|
|
||||||
if (check.Session is not null) return check.Session;
|
|
||||||
|
|
||||||
var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
Log.Fatal("Keine nutzbare Lizenz ({Status}) und {Variable} ist nicht gesetzt. " +
|
|
||||||
"Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
|
|
||||||
"über die Umgebungsvariable bereitstellen oder einmalig mit --license-set-key aktivieren.",
|
|
||||||
check.LastResult?.Status ?? "kein Schlüssel hinterlegt", LicenseKeyVariable);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
|
|
||||||
var (session, result) = await LicenseGuard.ActivateAsync(client, key);
|
|
||||||
if (session is not null)
|
|
||||||
{
|
|
||||||
Log.Information("Lizenz aktiviert ({Status}).", result.Status);
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Fatal("Aktivierung fehlgeschlagen ({Status}): {Message}", result.Status, result.Message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DcHeartbeatService? StartHeartbeat(PredictalyticsOptions options, PredictalyticsHost host)
|
|
||||||
{
|
|
||||||
if (!options.DcHeartbeatEnabled) return null;
|
|
||||||
if (string.IsNullOrWhiteSpace(options.DcToken))
|
|
||||||
{
|
|
||||||
Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var heartbeat = new DcHeartbeatService(
|
|
||||||
options.DcToken,
|
|
||||||
options.DcSource,
|
|
||||||
options.DcInstance,
|
|
||||||
options.DcHeartbeatIntervalSeconds,
|
|
||||||
ct => CollectSnapshotAsync(options, host, ct));
|
|
||||||
heartbeat.Start();
|
|
||||||
return heartbeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<DcHeartbeatSnapshot> CollectSnapshotAsync(
|
|
||||||
PredictalyticsOptions options, PredictalyticsHost host, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var snapshot = new DcHeartbeatSnapshot
|
|
||||||
{
|
|
||||||
Message = host.WorkersRunning ? "Worker laufen" : "Worker gestoppt"
|
|
||||||
};
|
|
||||||
|
|
||||||
snapshot.Metrics["workers_running"] = host.WorkersRunning ? 1 : 0;
|
|
||||||
snapshot.Metrics["webserver_running"] = host.WebServerRunning ? 1 : 0;
|
|
||||||
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.DbName))
|
|
||||||
{
|
|
||||||
var started = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
await using var conn = new MySqlConnector.MySqlConnection(options.ConnectionString);
|
|
||||||
await conn.OpenAsync(timeout.Token);
|
|
||||||
await using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT 1";
|
|
||||||
await cmd.ExecuteScalarAsync(timeout.Token);
|
|
||||||
|
|
||||||
snapshot.Checks["db"] = new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
snapshot.Checks["db"] = new DcCheck(false, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SIGTERM (systemd stop), SIGINT und Strg+C sauber behandeln, damit der Dienst
|
|
||||||
/// nicht hart abgeschossen wird.
|
|
||||||
/// </summary>
|
|
||||||
private static IDisposable RegisterShutdown(CancellationTokenSource cts)
|
|
||||||
{
|
|
||||||
var registrations = new List<IDisposable>();
|
|
||||||
|
|
||||||
void Handle(PosixSignalContext context)
|
|
||||||
{
|
|
||||||
context.Cancel = true; // Standardverhalten (sofortiges Beenden) unterdruecken
|
|
||||||
Log.Information("Signal {Signal} empfangen — fahre herunter…", context.Signal);
|
|
||||||
cts.Cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGTERM, Handle));
|
|
||||||
registrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGINT, Handle));
|
|
||||||
|
|
||||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
|
||||||
|
|
||||||
return new CompositeDisposable(registrations);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CompositeDisposable(List<IDisposable> items) : IDisposable
|
|
||||||
{
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var item in items)
|
|
||||||
{
|
|
||||||
try { item.Dispose(); } catch { /* beendet sich ohnehin */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
using Deploymentcenter.Client;
|
|
||||||
using Predictalytics.Hosting;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lizenzverwaltung von der Kommandozeile — die im Deploymentcenter-Integrations-
|
|
||||||
/// leitfaden vorgesehenen Schalter. Gedacht fuer Server ohne Oberflaeche, wo es
|
|
||||||
/// keinen Aktivierungsdialog geben kann.
|
|
||||||
/// </summary>
|
|
||||||
internal static class LicenseCli
|
|
||||||
{
|
|
||||||
public const string StatusSwitch = "--license-status";
|
|
||||||
public const string SetKeySwitch = "--license-set-key";
|
|
||||||
public const string DeactivateSwitch = "--license-deactivate";
|
|
||||||
|
|
||||||
public static bool IsLicenseCommand(string[] args) =>
|
|
||||||
args.Length > 0 && args[0] is StatusSwitch or SetKeySwitch or DeactivateSwitch;
|
|
||||||
|
|
||||||
public static async Task<int> RunAsync(string[] args)
|
|
||||||
{
|
|
||||||
LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
|
|
||||||
|
|
||||||
return args[0] switch
|
|
||||||
{
|
|
||||||
StatusSwitch => await ShowStatusAsync(),
|
|
||||||
SetKeySwitch => await SetKeyAsync(args.Length > 1 ? args[1] : null),
|
|
||||||
DeactivateSwitch => await DeactivateAsync(),
|
|
||||||
_ => HeadlessRunner.ExitFailed
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ShowStatusAsync()
|
|
||||||
{
|
|
||||||
var hw = LicenseGuard.GetHardwareInfo();
|
|
||||||
Console.WriteLine("Predictalytics — Lizenzstatus");
|
|
||||||
Console.WriteLine($" Produkt : {DcConfig.ProductSlug}");
|
|
||||||
Console.WriteLine($" Version : {DcConfig.AppVersion} ({DcConfig.GitCommitShort})");
|
|
||||||
Console.WriteLine($" Server : {DcConfig.BaseUrl}");
|
|
||||||
Console.WriteLine($" Hardware-ID : {hw.HardwareId}");
|
|
||||||
Console.WriteLine($" HWID-Quelle : {hw.HwidSource} (v{hw.HwidVersion}, {hw.Platform})");
|
|
||||||
Console.WriteLine($" Cache-Ablage : {LicenseGuard.StorageDirectory}");
|
|
||||||
|
|
||||||
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
|
||||||
if (string.IsNullOrWhiteSpace(cachedKey))
|
|
||||||
{
|
|
||||||
Console.WriteLine(" Schlüssel : keiner hinterlegt");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($"Aktivierung: {SetKeySwitch} <SCHLÜSSEL>");
|
|
||||||
return HeadlessRunner.ExitNoLicense;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($" Schlüssel : {Mask(cachedKey!)}");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.Write("Prüfe am Deployment Center… ");
|
|
||||||
|
|
||||||
var check = await LicenseGuard.TryUseCachedAsync();
|
|
||||||
var result = check.LastResult;
|
|
||||||
|
|
||||||
if (check.IsUsable)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"gültig ({result!.Status}{(result.IsCached ? ", aus lokalem Cache" : "")})");
|
|
||||||
if (result.CacheExpiresAt is { } expiresAt && expiresAt > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine($" Offline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC");
|
|
||||||
}
|
|
||||||
return HeadlessRunner.ExitOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transient heisst: kein Urteil, nur eine gescheiterte Verbindung.
|
|
||||||
Console.WriteLine(result?.IsTransient == true
|
|
||||||
? $"unentschieden ({result.Status}): {result.Message}"
|
|
||||||
: $"NICHT gültig ({result?.Status}): {result?.Message}");
|
|
||||||
return HeadlessRunner.ExitNoLicense;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> SetKeyAsync(string? key)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Aufruf: {SetKeySwitch} <SCHLÜSSEL>");
|
|
||||||
return HeadlessRunner.ExitFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Write($"Aktiviere {Mask(key.Trim().ToUpperInvariant())} … ");
|
|
||||||
var (session, result) = await LicenseGuard.ActivateAsync(LicenseGuard.CreateClient(), key);
|
|
||||||
|
|
||||||
if (session is not null)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"erfolgreich ({result.Status})");
|
|
||||||
Console.WriteLine($"Hardware-ID: {result.HardwareId}");
|
|
||||||
return HeadlessRunner.ExitOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Error.WriteLine($"fehlgeschlagen ({result.Status}): {result.Message}");
|
|
||||||
return HeadlessRunner.ExitNoLicense;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> DeactivateAsync()
|
|
||||||
{
|
|
||||||
var key = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("Kein hinterlegter Schlüssel — nichts zu deaktivieren.");
|
|
||||||
return HeadlessRunner.ExitFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Write("Gebe Aktivierungsplatz am Deployment Center frei… ");
|
|
||||||
var ok = await LicenseGuard.CreateClient()
|
|
||||||
.DeactivateAsync(DcConfig.ProductSlug, key!, DcConfig.BaseUrl);
|
|
||||||
Console.WriteLine(ok ? "erfolgreich" : "fehlgeschlagen");
|
|
||||||
return ok ? HeadlessRunner.ExitOk : HeadlessRunner.ExitFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Nur die Segmentstruktur zeigen, nicht den vollstaendigen Schluessel.</summary>
|
|
||||||
private static string Mask(string key)
|
|
||||||
{
|
|
||||||
var parts = key.Split('-');
|
|
||||||
if (parts.Length < 2) return "*****";
|
|
||||||
for (var i = 1; i < parts.Length - 1; i++) parts[i] = new string('*', parts[i].Length);
|
|
||||||
return string.Join('-', parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using Avalonia.Media;
|
|
||||||
using Serilog.Events;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>Eine Zeile der Terminalanzeige samt Einfaerbung nach Loglevel.</summary>
|
|
||||||
public sealed class LogLine
|
|
||||||
{
|
|
||||||
public string Text { get; }
|
|
||||||
public IBrush Foreground { get; }
|
|
||||||
|
|
||||||
public LogLine(string text, LogEventLevel level)
|
|
||||||
{
|
|
||||||
Text = text;
|
|
||||||
Foreground = BrushFor(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Farbschema unveraendert aus der WinForms-Fassung uebernommen.
|
|
||||||
private static IBrush BrushFor(LogEventLevel level) => level switch
|
|
||||||
{
|
|
||||||
LogEventLevel.Error or LogEventLevel.Fatal => new SolidColorBrush(Color.FromRgb(255, 82, 82)),
|
|
||||||
LogEventLevel.Warning => new SolidColorBrush(Color.FromRgb(255, 193, 7)),
|
|
||||||
LogEventLevel.Debug or LogEventLevel.Verbose => new SolidColorBrush(Color.FromRgb(158, 158, 158)),
|
|
||||||
_ => new SolidColorBrush(Color.FromRgb(76, 175, 80))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Predictalytics.Hosting;
|
|
||||||
using Serilog;
|
|
||||||
using Serilog.Events;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bindungsziel des Hauptfensters. Haelt den <see cref="PredictalyticsHost"/> und
|
|
||||||
/// uebersetzt dessen Zustand in Anzeigewerte.
|
|
||||||
/// </summary>
|
|
||||||
public sealed partial class MainWindowViewModel : ObservableObject
|
|
||||||
{
|
|
||||||
/// <summary>Obergrenze der Terminalzeilen. Aeltere werden vorne verworfen.</summary>
|
|
||||||
private const int MaxLogLines = 500;
|
|
||||||
|
|
||||||
private readonly PredictalyticsHost _host;
|
|
||||||
private CancellationTokenSource? _workerCts;
|
|
||||||
private DcHeartbeatService? _heartbeat;
|
|
||||||
private double? _lastDbSizeMb;
|
|
||||||
|
|
||||||
/// <summary>Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen.</summary>
|
|
||||||
public Func<string, string, Task>? ShowInfo { get; set; }
|
|
||||||
public Func<string, string, Task>? ShowError { get; set; }
|
|
||||||
public Func<string, string, Task<bool>>? ShowConfirm { get; set; }
|
|
||||||
|
|
||||||
public PredictalyticsOptions Options { get; }
|
|
||||||
|
|
||||||
public ObservableCollection<LogLine> LogLines { get; } = [];
|
|
||||||
|
|
||||||
[ObservableProperty] private string _statusText = "";
|
|
||||||
[ObservableProperty] private string _dbSizeText = "DB Size: —";
|
|
||||||
[ObservableProperty] private string _buildVersionText = "";
|
|
||||||
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
|
|
||||||
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
|
|
||||||
[ObservableProperty] private bool _isBusy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Die Lizenz, auf der dieser Lauf beruht — wird nach der Lizenzschranke gesetzt und
|
|
||||||
/// vom Menuepunkt „Lizenzstatus" gelesen. Das ViewModel entsteht bewusst vorher, damit
|
|
||||||
/// das Terminal schon waehrend der Aktivierung mitschreibt.
|
|
||||||
/// </summary>
|
|
||||||
public LicenseSession? License { get; set; }
|
|
||||||
|
|
||||||
public MainWindowViewModel(PredictalyticsOptions options)
|
|
||||||
{
|
|
||||||
Options = options;
|
|
||||||
_host = new PredictalyticsHost(Options);
|
|
||||||
_host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var buildDate = new FileInfo(GetType().Assembly.Location).LastWriteTime;
|
|
||||||
BuildVersionText = $"v{DcConfig.AppVersion} — Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
BuildVersionText = $"v{DcConfig.AppVersion}";
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Wird nach dem Serilog-Aufbau aufgerufen.</summary>
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
Log.Information("Shell initialisiert. Bereit.");
|
|
||||||
Log.Information("'Start Server' beginnt Polling und Discovery.");
|
|
||||||
Log.Information("'Start Webserver' startet die WebUI auf {Url}", Options.WebserverUrl);
|
|
||||||
Log.Information("Einstellungen: {Path}", PredictalyticsOptions.SettingsFilePath);
|
|
||||||
|
|
||||||
_ = RefreshDbSizeAsync();
|
|
||||||
var dbSizeTimer = new DispatcherTimer { Interval = TimeSpan.FromHours(6) };
|
|
||||||
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
|
|
||||||
dbSizeTimer.Start();
|
|
||||||
|
|
||||||
RestartHeartbeat();
|
|
||||||
|
|
||||||
if (Options.DcUpdateCheckEnabled)
|
|
||||||
{
|
|
||||||
_ = CheckForUpdatesAsync(silent: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
|
|
||||||
public void AppendLog(string message, LogEventLevel level)
|
|
||||||
{
|
|
||||||
var text = message.TrimEnd('\r', '\n');
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
LogLines.Add(new LogLine(text, level));
|
|
||||||
while (LogLines.Count > MaxLogLines) LogLines.RemoveAt(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateStatus()
|
|
||||||
{
|
|
||||||
var workers = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
|
|
||||||
var web = _host.WebServerRunning ? $"[RUNNING] Webserver :{Options.WebserverPort}" : "[STOPPED] Webserver";
|
|
||||||
StatusText = $"{workers} | {web}";
|
|
||||||
ServerButtonText = _host.WorkersRunning ? "⏹ Stop Server" : "▶ Start Server";
|
|
||||||
WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Deployment Center: Heartbeat ───
|
|
||||||
|
|
||||||
private void RestartHeartbeat()
|
|
||||||
{
|
|
||||||
_heartbeat?.Dispose();
|
|
||||||
_heartbeat = null;
|
|
||||||
|
|
||||||
if (!Options.DcHeartbeatEnabled) return;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(Options.DcToken))
|
|
||||||
{
|
|
||||||
Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token — bitte in den Einstellungen eintragen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_heartbeat = new DcHeartbeatService(
|
|
||||||
Options.DcToken,
|
|
||||||
Options.DcSource,
|
|
||||||
Options.DcInstance,
|
|
||||||
Options.DcHeartbeatIntervalSeconds,
|
|
||||||
CollectHeartbeatSnapshotAsync);
|
|
||||||
_heartbeat.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Assembles what this app knows about its own health. A heartbeat alone only proves that
|
|
||||||
/// a timer runs — the DB check is what shows whether the app can actually do its work.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<DcHeartbeatSnapshot> CollectHeartbeatSnapshotAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
var snapshot = new DcHeartbeatSnapshot
|
|
||||||
{
|
|
||||||
Message = _host.WorkersRunning ? "Worker laufen" : "Worker gestoppt"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deliberately no check for "workers stopped": that is a legitimate state chosen by
|
|
||||||
// the operator and would otherwise keep the monitor permanently on warning.
|
|
||||||
snapshot.Metrics["workers_running"] = _host.WorkersRunning ? 1 : 0;
|
|
||||||
snapshot.Metrics["webserver_running"] = _host.WebServerRunning ? 1 : 0;
|
|
||||||
snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1);
|
|
||||||
if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2);
|
|
||||||
|
|
||||||
var db = await ProbeDatabaseAsync(ct);
|
|
||||||
if (db is not null) snapshot.Checks["db"] = db;
|
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat.</summary>
|
|
||||||
private async Task<DcCheck?> ProbeDatabaseAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(Options.DbName)) return null;
|
|
||||||
|
|
||||||
var started = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
await using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString);
|
|
||||||
await conn.OpenAsync(timeout.Token);
|
|
||||||
await using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT 1";
|
|
||||||
await cmd.ExecuteScalarAsync(timeout.Token);
|
|
||||||
|
|
||||||
return new DcCheck(true, $"{started.ElapsedMilliseconds} ms", started.ElapsedMilliseconds);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new DcCheck(false, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Deployment Center: Updates und Lizenz ───
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Asks the UpdateService for a newer release. Silent at startup (log + status bar);
|
|
||||||
/// only a critical release interrupts the user.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
private Task CheckForUpdatesInteractiveAsync() => CheckForUpdatesAsync(silent: false);
|
|
||||||
|
|
||||||
private async Task CheckForUpdatesAsync(bool silent)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel, License?.LicenseKey);
|
|
||||||
|
|
||||||
// 401 is a licensing statement, not a network hiccup — and it must be caught
|
|
||||||
// before the "up to date" branch: the server answers no version at all, so
|
|
||||||
// silently reporting "aktuell" would hide that updates stopped arriving.
|
|
||||||
if (result.Unauthorized)
|
|
||||||
{
|
|
||||||
Log.Error("⛔ Die Release-Ablage weist die Lizenz zurück — es kommen keine Updates mehr an: {Message}",
|
|
||||||
result.Message);
|
|
||||||
BuildVersionText = $"v{DcConfig.AppVersion} — Update-Zugang abgelehnt";
|
|
||||||
if (!silent && ShowError != null)
|
|
||||||
await ShowError("Deployment Center",
|
|
||||||
"Die Release-Ablage hat den Lizenzschlüssel abgelehnt (401).\n\n" +
|
|
||||||
$"{result.Message}\n\n" +
|
|
||||||
"Das ist kein Netzwerkproblem: Die Lizenz ist abgelaufen oder widerrufen. " +
|
|
||||||
"Solange das so bleibt, erreichen diese Installation keine Updates.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Error is not null)
|
|
||||||
{
|
|
||||||
Log.Warning("Update-Prüfung fehlgeschlagen: {Message}", result.Message);
|
|
||||||
if (!silent && ShowError != null)
|
|
||||||
await ShowError("Deployment Center", $"Update-Prüfung fehlgeschlagen:\n{result.Message}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.UpdateAvailable)
|
|
||||||
{
|
|
||||||
Log.Information("Update-Prüfung: v{Version} ist aktuell (Kanal {Channel}).",
|
|
||||||
DcConfig.AppVersion, Options.DcUpdateChannel);
|
|
||||||
if (!silent && ShowInfo != null)
|
|
||||||
await ShowInfo("Deployment Center", $"Predictalytics v{DcConfig.AppVersion} ist aktuell.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var latest = result.LatestRelease?.Version ?? "?";
|
|
||||||
Log.Warning("⬆ Update verfügbar: v{Latest} (installiert: v{Current}, Kanal {Channel}){Critical}",
|
|
||||||
latest, DcConfig.AppVersion, Options.DcUpdateChannel, result.IsCritical ? " — KRITISCH" : "");
|
|
||||||
BuildVersionText = $"v{DcConfig.AppVersion} — Update v{latest} verfügbar";
|
|
||||||
|
|
||||||
if (silent && !result.IsCritical) return;
|
|
||||||
|
|
||||||
var notes = result.LatestRelease?.Changelog;
|
|
||||||
var agentPresent = DcUpdateService.FindUpdateAgent() is not null;
|
|
||||||
var text = $"Neues Release v{latest} verfügbar (installiert: v{DcConfig.AppVersion}).\n" +
|
|
||||||
(result.IsCritical ? "\nDieses Update ist als kritisch markiert.\n" : "") +
|
|
||||||
(string.IsNullOrWhiteSpace(notes) ? "" : $"\n{notes}\n") +
|
|
||||||
(agentPresent
|
|
||||||
? "\nJetzt installieren? Predictalytics wird dazu beendet."
|
|
||||||
: "\nDer Update-Agent liegt nicht neben der Anwendung — bitte manuell einspielen.");
|
|
||||||
|
|
||||||
if (!agentPresent)
|
|
||||||
{
|
|
||||||
if (ShowInfo != null) await ShowInfo("Deployment Center", text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ShowConfirm == null || !await ShowConfirm("Deployment Center", text)) return;
|
|
||||||
|
|
||||||
// The agent replaces the running installation, so announce the shutdown first —
|
|
||||||
// otherwise the monitor reports a crash a few minutes later.
|
|
||||||
_heartbeat?.NotifyStopping();
|
|
||||||
if (!DcUpdateService.LaunchAgent(Options.DcUpdateChannel, License?.LicenseKey) && ShowError != null)
|
|
||||||
{
|
|
||||||
await ShowError("Deployment Center",
|
|
||||||
"Der Update-Agent konnte nicht gestartet werden. Details stehen im Log.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Update-Prüfung fehlgeschlagen");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ShowLicenseStatusAsync()
|
|
||||||
{
|
|
||||||
if (ShowInfo == null) return;
|
|
||||||
|
|
||||||
var hardware = LicenseGuard.GetHardwareInfo();
|
|
||||||
var result = License?.LastResult;
|
|
||||||
|
|
||||||
var grace = result?.CacheExpiresAt is { } expiresAt && expiresAt > 0
|
|
||||||
? $"\nOffline-Gnadenfrist bis: {DateTimeOffset.FromUnixTimeSeconds(expiresAt).UtcDateTime:yyyy-MM-dd HH:mm} UTC"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
await ShowInfo("Lizenzstatus",
|
|
||||||
$"Produkt: {DcConfig.ProductSlug}\n" +
|
|
||||||
$"Version: {DcConfig.AppVersion} ({DcConfig.GitCommitShort})\n" +
|
|
||||||
$"Server: {DcConfig.BaseUrl}\n" +
|
|
||||||
$"Hardware-ID: {hardware.HardwareId}\n" +
|
|
||||||
$"Quelle: {hardware.HwidSource}\n\n" +
|
|
||||||
$"Letzte Prüfung: {result?.Status ?? "unbekannt"}" +
|
|
||||||
(result?.IsCached == true ? " (aus Offline-Cache)" : "") +
|
|
||||||
$"\n{result?.Message}{grace}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Befehle ───
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ToggleServerAsync()
|
|
||||||
{
|
|
||||||
if (!_host.WorkersRunning)
|
|
||||||
{
|
|
||||||
_workerCts = new CancellationTokenSource();
|
|
||||||
Log.Information("🚀 Starte Hintergrund-Worker...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _host.StartWorkersAsync(_workerCts.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Information("⏹ Stoppe Hintergrund-Worker...");
|
|
||||||
_workerCts?.Cancel();
|
|
||||||
Log.Information("Worker gestoppt.");
|
|
||||||
}
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ToggleWebserverAsync()
|
|
||||||
{
|
|
||||||
if (!_host.WebServerRunning)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Log.Information("🌐 Starte Kestrel auf {Url}...", Options.WebserverUrl);
|
|
||||||
await _host.StartWebServerAsync();
|
|
||||||
Log.Information("✅ WebUI erreichbar unter {Url}", Options.WebserverUrl);
|
|
||||||
Log.Information("📄 Swagger unter {Url}/swagger", Options.WebserverUrl);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Webserver konnte nicht gestartet werden");
|
|
||||||
if (ShowError != null) await ShowError("Fehler", $"Webserver konnte nicht gestartet werden:\n{ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Information("⏹ Stoppe Webserver...");
|
|
||||||
await _host.StopWebServerAsync();
|
|
||||||
Log.Information("Webserver gestoppt.");
|
|
||||||
}
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void SaveSettings()
|
|
||||||
{
|
|
||||||
Options.Save();
|
|
||||||
DcErrorReporter.Configure(Options.DcToken, Options.DcErrorReportingEnabled);
|
|
||||||
RestartHeartbeat();
|
|
||||||
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
|
|
||||||
UpdateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenWebUi() => OpenInShell(Options.WebserverUrl);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenLogFolder()
|
|
||||||
{
|
|
||||||
var path = LoggingSetup.DefaultLogDirectory;
|
|
||||||
OpenInShell(Directory.Exists(path) ? path : AppContext.BaseDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task SyncMarketsAsync()
|
|
||||||
{
|
|
||||||
if (!await GuardWorkersStoppedAsync("Der Markt-Sync")) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
Log.Information("Manueller Markt-Sync gestartet...");
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
await _host.RunSingleMarketSyncAsync(cts.Token);
|
|
||||||
Log.Information("Manueller Markt-Sync abgeschlossen.");
|
|
||||||
if (ShowInfo != null) await ShowInfo("Fertig", "Markt-Sync abgeschlossen.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Manueller Markt-Sync fehlgeschlagen");
|
|
||||||
if (ShowError != null) await ShowError("Fehler", $"Markt-Sync fehlgeschlagen:\n{ex.Message}");
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task UpdateDatabaseAsync()
|
|
||||||
{
|
|
||||||
if (!await GuardWorkersStoppedAsync("Das Datenbank-Update")) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
Log.Information("Manuelles Datenbank-Update gestartet...");
|
|
||||||
await _host.UpdateDatabaseAsync();
|
|
||||||
Log.Information("Datenbank aktualisiert.");
|
|
||||||
if (ShowInfo != null) await ShowInfo("Fertig", "Datenbank-Update abgeschlossen.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Manuelles Datenbank-Update fehlgeschlagen");
|
|
||||||
if (ShowError != null) await ShowError("Fehler", $"Datenbank-Update fehlgeschlagen:\n{ex.Message}");
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task RecalculateAllAsync()
|
|
||||||
{
|
|
||||||
if (!await GuardWorkersStoppedAsync("Die Neuberechnung")) return;
|
|
||||||
|
|
||||||
if (ShowConfirm == null) return;
|
|
||||||
var confirmed = await ShowConfirm("Alle Trader neu berechnen",
|
|
||||||
"Dies löscht alle ABGELEITETEN Analysedaten (Positionen, Tages-Snapshots, " +
|
|
||||||
"Kategorie-Statistiken) und markiert jeden Trader zur vollständigen Neuberechnung.\n\n" +
|
|
||||||
"Rohdaten (Trades und Märkte) bleiben unberührt.\n\n" +
|
|
||||||
"Danach den Server starten: der Analytics-Worker baut jeden Trader mit der " +
|
|
||||||
"aktuellen Engine neu auf (läuft im Hintergrund, kann bei vielen Tradern " +
|
|
||||||
"mehrere Stunden dauern).\n\nFortfahren?");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
Log.Information("Vollständige Neuberechnung angestoßen...");
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
var summary = await _host.RunRecalculateAllTradersAsync(cts.Token);
|
|
||||||
if (ShowInfo != null)
|
|
||||||
await ShowInfo("Alle Trader neu berechnen",
|
|
||||||
$"Zurücksetzen abgeschlossen:\n\n{summary}\n\nJetzt den Server starten, um die Analytik neu aufzubauen.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Vollständige Neuberechnung fehlgeschlagen");
|
|
||||||
if (ShowError != null) await ShowError("Fehler", $"Neuberechnung fehlgeschlagen:\n{ex.Message}");
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Hilfsfunktionen ───
|
|
||||||
|
|
||||||
private async Task<bool> GuardWorkersStoppedAsync(string action)
|
|
||||||
{
|
|
||||||
if (!_host.WorkersRunning) return true;
|
|
||||||
if (ShowError != null)
|
|
||||||
await ShowError("Worker aktiv", $"{action} kann nicht laufen, während die Hintergrund-Worker aktiv sind. Bitte zuerst den Server stoppen.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshDbSizeAsync()
|
|
||||||
{
|
|
||||||
var sizeMb = await _host.GetDatabaseSizeMbAsync();
|
|
||||||
_lastDbSizeMb = sizeMb;
|
|
||||||
DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Oeffnet Pfad oder URL mit der Standardanwendung des Systems.
|
|
||||||
/// UseShellExecute funktioniert unter Windows wie unter Linux (dort ueber xdg-open).
|
|
||||||
/// </summary>
|
|
||||||
private static void OpenInShell(string target)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Konnte {Target} nicht öffnen", target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Beim Beenden: Monitor abmelden, Worker und Webserver stoppen.</summary>
|
|
||||||
public void Shutdown()
|
|
||||||
{
|
|
||||||
_heartbeat?.NotifyStopping();
|
|
||||||
_heartbeat?.Dispose();
|
|
||||||
_heartbeat = null;
|
|
||||||
_workerCts?.Cancel();
|
|
||||||
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using MySqlConnector;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>Feste Auswahllisten fuer die Einstellungsansicht.</summary>
|
|
||||||
public static class OptionSources
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Die praktisch relevanten SSL-Modi. Die uebrigen Werte des Enums
|
|
||||||
/// (VerifyCA, VerifyFull) erfordern hinterlegte Zertifikate und wuerden
|
|
||||||
/// hier nur zu Fehlkonfiguration einladen.
|
|
||||||
/// </summary>
|
|
||||||
public static MySqlSslMode[] SslModes { get; } =
|
|
||||||
[
|
|
||||||
MySqlSslMode.None,
|
|
||||||
MySqlSslMode.Preferred,
|
|
||||||
MySqlSslMode.Required
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>Release-Kanaele des UpdateService.</summary>
|
|
||||||
public static string[] UpdateChannels { get; } = ["prod", "beta", "dev"];
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Views;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimale Ersatzdialoge fuer <c>MessageBox.Show</c>. Bewusst im Code aufgebaut
|
|
||||||
/// und ohne Drittanbieterpaket — es sind nur zwei Varianten noetig.
|
|
||||||
/// </summary>
|
|
||||||
public static class Dialogs
|
|
||||||
{
|
|
||||||
public static Task ShowInfoAsync(Window owner, string title, string message) =>
|
|
||||||
ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
|
|
||||||
|
|
||||||
public static Task ShowErrorAsync(Window owner, string title, string message) =>
|
|
||||||
ShowAsync(owner, title, message, ["OK"]).ContinueWith(_ => { });
|
|
||||||
|
|
||||||
public static async Task<bool> ShowConfirmAsync(Window owner, string title, string message) =>
|
|
||||||
await ShowAsync(owner, title, message, ["Ja", "Abbrechen"]) == 0;
|
|
||||||
|
|
||||||
private static Task<int> ShowAsync(Window owner, string title, string message, string[] buttons)
|
|
||||||
{
|
|
||||||
var tcs = new TaskCompletionSource<int>();
|
|
||||||
|
|
||||||
var buttonPanel = new StackPanel
|
|
||||||
{
|
|
||||||
Orientation = Orientation.Horizontal,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
|
||||||
Spacing = 8
|
|
||||||
};
|
|
||||||
|
|
||||||
var dialog = new Window
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
SizeToContent = SizeToContent.WidthAndHeight,
|
|
||||||
MaxWidth = 640,
|
|
||||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
|
||||||
CanResize = false,
|
|
||||||
ShowInTaskbar = false
|
|
||||||
};
|
|
||||||
|
|
||||||
for (var i = 0; i < buttons.Length; i++)
|
|
||||||
{
|
|
||||||
var index = i;
|
|
||||||
var button = new Button
|
|
||||||
{
|
|
||||||
Content = buttons[i],
|
|
||||||
MinWidth = 90,
|
|
||||||
IsDefault = index == 0,
|
|
||||||
IsCancel = index == buttons.Length - 1
|
|
||||||
};
|
|
||||||
button.Click += (_, _) => { tcs.TrySetResult(index); dialog.Close(); };
|
|
||||||
buttonPanel.Children.Add(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.Content = new StackPanel
|
|
||||||
{
|
|
||||||
Margin = new Avalonia.Thickness(20),
|
|
||||||
Spacing = 16,
|
|
||||||
Children =
|
|
||||||
{
|
|
||||||
new TextBlock
|
|
||||||
{
|
|
||||||
Text = message,
|
|
||||||
TextWrapping = TextWrapping.Wrap,
|
|
||||||
MaxWidth = 580
|
|
||||||
},
|
|
||||||
buttonPanel
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
|
|
||||||
dialog.Closed += (_, _) => tcs.TrySetResult(buttons.Length - 1);
|
|
||||||
|
|
||||||
_ = dialog.ShowDialog(owner);
|
|
||||||
return tcs.Task;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
x:Class="Predictalytics.Shell.Views.LicenseWindow"
|
|
||||||
Title="Predictalytics — Lizenzaktivierung"
|
|
||||||
Width="580" SizeToContent="Height"
|
|
||||||
CanResize="False"
|
|
||||||
WindowStartupLocation="CenterScreen">
|
|
||||||
|
|
||||||
<StackPanel Margin="24" Spacing="16">
|
|
||||||
|
|
||||||
<TextBlock TextWrapping="Wrap" FontSize="15" FontWeight="SemiBold"
|
|
||||||
Text="Diese Installation benötigt eine gültige Lizenz." />
|
|
||||||
|
|
||||||
<TextBlock TextWrapping="Wrap" Opacity="0.8"
|
|
||||||
Text="Bitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):" />
|
|
||||||
|
|
||||||
<TextBox Name="KeyBox"
|
|
||||||
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
|
|
||||||
FontSize="14"
|
|
||||||
PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
|
|
||||||
|
|
||||||
<TextBlock Name="StatusText" TextWrapping="Wrap" MinHeight="34" IsVisible="False" />
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="12">
|
|
||||||
<ProgressBar Name="Busy" IsIndeterminate="True" Width="100"
|
|
||||||
VerticalAlignment="Center" IsVisible="False" />
|
|
||||||
<Button Name="ActivateButton" Content="Aktivieren" MinWidth="110" IsDefault="True"
|
|
||||||
Click="OnActivateClick" />
|
|
||||||
<Button Content="Beenden" MinWidth="100" IsCancel="True" Click="OnExitClick" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Die Aktivierung wird am Server an diese Hardware-ID gebunden. -->
|
|
||||||
<Border BorderThickness="0,1,0,0" Padding="0,12,0,0"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="Hardware-ID dieser Maschine" FontSize="11" Opacity="0.6" />
|
|
||||||
<SelectableTextBlock Name="HardwareIdText"
|
|
||||||
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
|
|
||||||
FontSize="11" Opacity="0.8" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</Window>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Deploymentcenter.Client;
|
|
||||||
using Predictalytics.Hosting;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Views;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
|
|
||||||
/// <see cref="Completion"/> liefert die Sitzung, sobald das Deployment Center die
|
|
||||||
/// Aktivierung fuer diese Maschine bestaetigt hat; null, wenn der Benutzer abbricht.
|
|
||||||
/// </summary>
|
|
||||||
public partial class LicenseWindow : Window
|
|
||||||
{
|
|
||||||
private readonly LicenseClient _client = null!;
|
|
||||||
private readonly TaskCompletionSource<LicenseSession?> _completion = new();
|
|
||||||
|
|
||||||
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
|
|
||||||
public Task<LicenseSession?> Completion => _completion.Task;
|
|
||||||
|
|
||||||
public LicenseWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
// Die Aktivierung wird am Server an diese Hardware-ID gebunden — ohne sie kann
|
|
||||||
// der Support nicht sagen, welcher Platz beim Maschinenwechsel freizugeben ist.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hw = LicenseGuard.GetHardwareInfo();
|
|
||||||
HardwareIdText.Text = $"{hw.HardwareId}\nQuelle: {hw.HwidSource}";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
HardwareIdText.Text = $"nicht ermittelbar: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public LicenseWindow(LicenseClient client, LicenseValidationResult? lastResult) : this()
|
|
||||||
{
|
|
||||||
_client = client;
|
|
||||||
|
|
||||||
if (lastResult is null) return;
|
|
||||||
|
|
||||||
// IsTransient heisst: der Server hat gar kein Urteil gefaellt. Dem Benutzer zu
|
|
||||||
// sagen, seine Lizenz sei schlecht, waere falsch — die Verbindung ist es.
|
|
||||||
SetStatus(lastResult.IsTransient
|
|
||||||
? "Das Deployment Center ist derzeit nicht erreichbar und es liegt keine gültige " +
|
|
||||||
$"Offline-Prüfung mehr vor. Bitte Verbindung prüfen und erneut versuchen.\n{lastResult.Message}"
|
|
||||||
: $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}",
|
|
||||||
isError: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnActivateClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var key = KeyBox.Text ?? "";
|
|
||||||
if (string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
SetStatus("Bitte einen Lizenzschlüssel eingeben.", isError: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivateButton.IsEnabled = false;
|
|
||||||
Busy.IsVisible = true;
|
|
||||||
SetStatus("Prüfe Lizenz am Deployment Center…", isError: false);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (session, result) = await LicenseGuard.ActivateAsync(_client, key);
|
|
||||||
if (session is not null)
|
|
||||||
{
|
|
||||||
_completion.TrySetResult(session);
|
|
||||||
Close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SetStatus($"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}", isError: true);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
SetStatus($"Fehler bei der Prüfung: {ex.Message}", isError: true);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ActivateButton.IsEnabled = true;
|
|
||||||
Busy.IsVisible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnExitClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_completion.TrySetResult(null);
|
|
||||||
Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
|
||||||
{
|
|
||||||
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
|
|
||||||
_completion.TrySetResult(null);
|
|
||||||
base.OnClosed(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetStatus(string message, bool isError)
|
|
||||||
{
|
|
||||||
StatusText.Text = message;
|
|
||||||
StatusText.IsVisible = true;
|
|
||||||
StatusText.Foreground = isError
|
|
||||||
? new SolidColorBrush(Color.FromRgb(205, 92, 92))
|
|
||||||
: new SolidColorBrush(Color.FromRgb(150, 150, 150));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:Predictalytics.Shell.ViewModels"
|
|
||||||
xmlns:hosting="using:Predictalytics.Hosting"
|
|
||||||
xmlns:sql="using:MySqlConnector"
|
|
||||||
xmlns:sys="using:System"
|
|
||||||
x:Class="Predictalytics.Shell.Views.MainWindow"
|
|
||||||
x:DataType="vm:MainWindowViewModel"
|
|
||||||
Title="Predictalytics Analytics — Backend Server"
|
|
||||||
Width="1400" Height="900"
|
|
||||||
MinWidth="900" MinHeight="600"
|
|
||||||
WindowStartupLocation="CenterScreen">
|
|
||||||
|
|
||||||
<Window.Resources>
|
|
||||||
<!-- Monospace mit Fallback-Kette: Cascadia gibt es unter Linux meist nicht. -->
|
|
||||||
<FontFamily x:Key="TerminalFont">Cascadia Code,DejaVu Sans Mono,Liberation Mono,Consolas,monospace</FontFamily>
|
|
||||||
</Window.Resources>
|
|
||||||
|
|
||||||
<Window.Styles>
|
|
||||||
<Style Selector="TextBlock.label">
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
<Setter Property="Margin" Value="0,0,12,0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.hint">
|
|
||||||
<Setter Property="Opacity" Value="0.6" />
|
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="Margin" Value="0,2,0,0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="HeaderedContentControl.group">
|
|
||||||
<Setter Property="Margin" Value="0,0,0,20" />
|
|
||||||
</Style>
|
|
||||||
</Window.Styles>
|
|
||||||
|
|
||||||
<DockPanel>
|
|
||||||
|
|
||||||
<!-- ─── Menü ─── -->
|
|
||||||
<Menu DockPanel.Dock="Top">
|
|
||||||
<MenuItem Header="_Datei">
|
|
||||||
<MenuItem Header="_Beenden" Click="OnExitClick" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="_Ansicht">
|
|
||||||
<MenuItem Header="Log-Ordner öffnen" Command="{Binding OpenLogFolderCommand}" />
|
|
||||||
<MenuItem Header="WebUI öffnen" Command="{Binding OpenWebUiCommand}" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="_Entwicklung">
|
|
||||||
<MenuItem Header="Märkte synchronisieren" Command="{Binding SyncMarketsCommand}" />
|
|
||||||
<MenuItem Header="Datenbank aktualisieren" Command="{Binding UpdateDatabaseCommand}" />
|
|
||||||
<Separator />
|
|
||||||
<MenuItem Header="Alle Trader neu berechnen…" Command="{Binding RecalculateAllCommand}" />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem Header="Deployment _Center">
|
|
||||||
<MenuItem Header="Nach Updates suchen" Command="{Binding CheckForUpdatesInteractiveCommand}" />
|
|
||||||
<MenuItem Header="Lizenzstatus anzeigen" Command="{Binding ShowLicenseStatusCommand}" />
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<!-- ─── Werkzeugleiste ─── -->
|
|
||||||
<Border DockPanel.Dock="Top" Padding="8" BorderThickness="0,0,0,1"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<Button Content="{Binding ServerButtonText}" Command="{Binding ToggleServerCommand}" MinWidth="150" />
|
|
||||||
<Button Content="{Binding WebserverButtonText}" Command="{Binding ToggleWebserverCommand}" MinWidth="170" />
|
|
||||||
<ProgressBar IsIndeterminate="True" Width="120" VerticalAlignment="Center"
|
|
||||||
IsVisible="{Binding IsBusy}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ─── Statusleiste ─── -->
|
|
||||||
<Border DockPanel.Dock="Bottom" Padding="8,4" BorderThickness="0,1,0,0"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
|
||||||
<TextBlock Grid.Column="0" Text="{Binding StatusText}" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Grid.Column="1" Text="{Binding DbSizeText}" Margin="16,0" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Grid.Column="2" Text="{Binding BuildVersionText}" VerticalAlignment="Center" Opacity="0.7" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ─── Inhalt ─── -->
|
|
||||||
<TabControl>
|
|
||||||
|
|
||||||
<TabItem Header="Terminal">
|
|
||||||
<ListBox Name="LogList"
|
|
||||||
ItemsSource="{Binding LogLines}"
|
|
||||||
Background="#0F0F14"
|
|
||||||
FontFamily="{StaticResource TerminalFont}"
|
|
||||||
FontSize="12"
|
|
||||||
SelectionMode="Multiple">
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:LogLine">
|
|
||||||
<TextBlock Text="{Binding Text}"
|
|
||||||
Foreground="{Binding Foreground}"
|
|
||||||
TextWrapping="NoWrap" />
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem Header="Einstellungen">
|
|
||||||
<Grid RowDefinitions="*,Auto">
|
|
||||||
<ScrollViewer Grid.Row="0" Padding="20">
|
|
||||||
<StackPanel MaxWidth="760" HorizontalAlignment="Left"
|
|
||||||
DataContext="{Binding Options}" x:DataType="hosting:PredictalyticsOptions">
|
|
||||||
|
|
||||||
<!-- Webserver -->
|
|
||||||
<HeaderedContentControl Classes="group" Header="Webserver">
|
|
||||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Port" />
|
|
||||||
<NumericUpDown Grid.Column="1" Value="{Binding WebserverPort}"
|
|
||||||
Minimum="1" Maximum="65535" FormatString="0" HorizontalAlignment="Left" Width="160" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Bind-Adresse" />
|
|
||||||
<StackPanel Grid.Column="1">
|
|
||||||
<TextBox Text="{Binding WebserverHost}" Width="260" HorizontalAlignment="Left" />
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="'localhost' = nur lokal erreichbar. '0.0.0.0' macht die API von außen erreichbar — dann unbedingt absichern." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="API-Token" />
|
|
||||||
<StackPanel Grid.Column="1">
|
|
||||||
<TextBox Text="{Binding ApiControlToken}" PasswordChar="•" />
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="Schützt die schreibenden Endpunkte (Jobs, Trader-Steuerung, Dev) über den Header X-Predictalytics-Key. Bei einer Bind-Adresse außerhalb von localhost Pflicht — ohne Token werden diese Endpunkte gar nicht bereitgestellt." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank-Debug" />
|
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding DbConnectionDebug}"
|
|
||||||
Content="Ausführliche Verbindungsinfos im Terminal" />
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</HeaderedContentControl>
|
|
||||||
|
|
||||||
<!-- Datenbank -->
|
|
||||||
<HeaderedContentControl Classes="group" Header="Datenbank">
|
|
||||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Server" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding DbServer}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Datenbank" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding DbName}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Benutzer" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding DbUser}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Passwort" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding DbPassword}" PasswordChar="•" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="SSL-Modus" />
|
|
||||||
<StackPanel Grid.Column="1">
|
|
||||||
<ComboBox SelectedItem="{Binding DbSslMode}" Width="200" HorizontalAlignment="Left"
|
|
||||||
ItemsSource="{x:Static vm:OptionSources.SslModes}" />
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="Bei entfernten Servern 'Preferred' empfohlen. 'None' überträgt unverschlüsselt." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</HeaderedContentControl>
|
|
||||||
|
|
||||||
<!-- Deployment Center -->
|
|
||||||
<HeaderedContentControl Classes="group" Header="Deployment Center">
|
|
||||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Server-URL" />
|
|
||||||
<StackPanel Grid.Column="1">
|
|
||||||
<SelectableTextBlock Text="{Binding DcServerUrl}" VerticalAlignment="Center" Opacity="0.8" />
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="Fest einkompiliert — ein einstellbarer Endpoint würde erlauben, die App auf einen gefälschten Lizenz- oder Update-Server zu zeigen." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="API Token" />
|
|
||||||
<StackPanel Grid.Column="1">
|
|
||||||
<TextBox Text="{Binding DcToken}" PasswordChar="•" />
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="Benötigte Rechte: 'watchdog:ping' für Heartbeats, 'bugtracker:report' für das Fehler-Reporting." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Heartbeat aktiv" />
|
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding DcHeartbeatEnabled}"
|
|
||||||
Content="Heartbeats senden (Dead-Man's-Switch)" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Monitor Source" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding DcSource}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Monitor Instance" />
|
|
||||||
<TextBox Grid.Column="1" Text="{Binding DcInstance}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Intervall (Sek.)" />
|
|
||||||
<StackPanel Grid.Column="1">
|
|
||||||
<NumericUpDown Value="{Binding DcHeartbeatIntervalSeconds}"
|
|
||||||
Minimum="15" Maximum="3600" FormatString="0" HorizontalAlignment="Left" Width="160" />
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="Der Evaluator stuft nach dem Doppelten auf 'warning' und nach dem Vierfachen auf 'down'." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Fehler melden" />
|
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding DcErrorReportingEnabled}"
|
|
||||||
Content="Laufzeitfehler (Error/Fatal) an den Fehler-Stream melden" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Update-Prüfung" />
|
|
||||||
<CheckBox Grid.Column="1" IsChecked="{Binding DcUpdateCheckEnabled}"
|
|
||||||
Content="Beim Start auf neuere Releases prüfen (installiert nichts automatisch)" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="200,*">
|
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Update-Kanal" />
|
|
||||||
<ComboBox Grid.Column="1" SelectedItem="{Binding DcUpdateChannel}" Width="200"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
ItemsSource="{x:Static vm:OptionSources.UpdateChannels}" />
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</HeaderedContentControl>
|
|
||||||
|
|
||||||
<!-- Egress -->
|
|
||||||
<HeaderedContentControl Classes="group" Header="Egress (Proxy / Quell-IP)">
|
|
||||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
|
||||||
<TextBlock Classes="hint"
|
|
||||||
Text="Ein Kanal pro Zeile im Format: id|type|value Beispiel: prox-1|Proxy|http://user:pass@proxy:8080 ip-1|SourceIp|192.168.1.100" />
|
|
||||||
<TextBox Text="{Binding EgressChannelsText}"
|
|
||||||
AcceptsReturn="True"
|
|
||||||
TextWrapping="NoWrap"
|
|
||||||
MinHeight="120"
|
|
||||||
FontFamily="{StaticResource TerminalFont}"
|
|
||||||
FontSize="12" />
|
|
||||||
</StackPanel>
|
|
||||||
</HeaderedContentControl>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<Border Grid.Row="1" Padding="20,12" BorderThickness="0,1,0,0"
|
|
||||||
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
|
||||||
<Button Content="Speichern & übernehmen" Command="{Binding SaveSettingsCommand}" />
|
|
||||||
<TextBlock VerticalAlignment="Center" Opacity="0.6"
|
|
||||||
Text="Port und Datenbank wirken beim nächsten Start des Webservers." />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
</TabControl>
|
|
||||||
</DockPanel>
|
|
||||||
</Window>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using System.Collections.Specialized;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Predictalytics.Shell.ViewModels;
|
|
||||||
|
|
||||||
namespace Predictalytics.Shell.Views;
|
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
|
||||||
{
|
|
||||||
private MainWindowViewModel? _vm;
|
|
||||||
|
|
||||||
public MainWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
public MainWindow(MainWindowViewModel viewModel) : this()
|
|
||||||
{
|
|
||||||
_vm = viewModel;
|
|
||||||
DataContext = viewModel;
|
|
||||||
|
|
||||||
// Dialoge kann nur das Fenster zeigen — das ViewModel bekommt sie als Rueckrufe.
|
|
||||||
viewModel.ShowInfo = (title, message) => Dialogs.ShowInfoAsync(this, title, message);
|
|
||||||
viewModel.ShowError = (title, message) => Dialogs.ShowErrorAsync(this, title, message);
|
|
||||||
viewModel.ShowConfirm = (title, message) => Dialogs.ShowConfirmAsync(this, title, message);
|
|
||||||
|
|
||||||
// Terminal mitlaufen lassen.
|
|
||||||
viewModel.LogLines.CollectionChanged += OnLogLinesChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLogLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Action != NotifyCollectionChangedAction.Add) return;
|
|
||||||
|
|
||||||
// Nach dem Layout ans Ende scrollen, sonst existiert der Container noch nicht.
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
var list = this.FindControl<ListBox>("LogList");
|
|
||||||
if (list?.ItemCount > 0) list.ScrollIntoView(list.ItemCount - 1);
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnExitClick(object? sender, RoutedEventArgs e) => Close();
|
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
|
||||||
{
|
|
||||||
if (_vm != null)
|
|
||||||
{
|
|
||||||
_vm.LogLines.CollectionChanged -= OnLogLinesChanged;
|
|
||||||
_vm.Shutdown();
|
|
||||||
_vm = null;
|
|
||||||
}
|
|
||||||
base.OnClosed(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
chmod +x "$SCRIPT_DIR/Predictalytics.Shell" "$SCRIPT_DIR/update-agent" 2>/dev/null
|
|
||||||
exec "$SCRIPT_DIR/Predictalytics.Shell" "$@"
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
private const string FileName = "settings.json";
|
||||||
|
|
||||||
|
[Category("Webserver")]
|
||||||
|
[DisplayName("Port")]
|
||||||
|
[Description("Der Port, über den die WebUI und API erreichbar sind.")]
|
||||||
|
[DefaultValue(5000)]
|
||||||
|
public int WebserverPort { get; set; } = 5000;
|
||||||
|
|
||||||
|
[Category("Webserver")]
|
||||||
|
[DisplayName("Database Debug")]
|
||||||
|
[Description("Wenn aktiv, werden detaillierte Verbindungsinformationen im Terminal angezeigt.")]
|
||||||
|
[DefaultValue(false)]
|
||||||
|
public bool DbConnectionDebug { get; set; } = false;
|
||||||
|
|
||||||
|
private string _egressChannelsText = "";
|
||||||
|
|
||||||
|
[Category("Egress (Proxy/IP)")]
|
||||||
|
[DisplayName("Egress Channels")]
|
||||||
|
[Description("Liste der Egress-Kanäle im Format: id|type|value (Zeilengetrennt). Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
|
||||||
|
[Editor("System.ComponentModel.Design.MultilineStringEditor, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", typeof(System.Drawing.Design.UITypeEditor))]
|
||||||
|
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
|
||||||
|
|
||||||
|
[Category("Watchdog")]
|
||||||
|
[DisplayName("Enabled")]
|
||||||
|
[Description("Sendet periodische Heartbeats an den externen Watchdog-Server (Dead-Man's-Switch). Benötigt einen API Key.")]
|
||||||
|
[DefaultValue(true)]
|
||||||
|
public bool WatchdogEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
[Category("Watchdog")]
|
||||||
|
[DisplayName("Server URL")]
|
||||||
|
[Description("Basis-URL des Watchdog-Servers.")]
|
||||||
|
[DefaultValue("https://watchdog.mhdf.de")]
|
||||||
|
public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de";
|
||||||
|
|
||||||
|
[Category("Watchdog")]
|
||||||
|
[DisplayName("API Key")]
|
||||||
|
[Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")]
|
||||||
|
[PasswordPropertyText(true)]
|
||||||
|
public string WatchdogApiKey { get; set; } = "";
|
||||||
|
|
||||||
|
[Category("Watchdog")]
|
||||||
|
[DisplayName("Source")]
|
||||||
|
[Description("Eindeutiger Monitor-Name dieses Dienstes im Watchdog-Dashboard.")]
|
||||||
|
[DefaultValue("Predictalytics")]
|
||||||
|
public string WatchdogSource { get; set; } = "Predictalytics";
|
||||||
|
|
||||||
|
[Category("Watchdog")]
|
||||||
|
[DisplayName("Instance")]
|
||||||
|
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
|
||||||
|
[DefaultValue("default")]
|
||||||
|
public string WatchdogInstance { get; set; } = "default";
|
||||||
|
|
||||||
|
[Category("Watchdog")]
|
||||||
|
[DisplayName("Interval (Sekunden)")]
|
||||||
|
[Description("Sende-Takt der Heartbeats. Der Watchdog alarmiert, wenn ~1,5× dieses Intervall + 30 s ohne Heartbeat vergehen.")]
|
||||||
|
[DefaultValue(60)]
|
||||||
|
public int WatchdogIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
private string _dbServer = "localhost";
|
||||||
|
private string _dbName = "";
|
||||||
|
private string _dbUser = "";
|
||||||
|
private string _dbPassword = "";
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("Server")]
|
||||||
|
public string DbServer { get => _dbServer; set => _dbServer = string.IsNullOrWhiteSpace(value) ? _dbServer : value.Trim(); }
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("Database")]
|
||||||
|
public string DbName { get => _dbName; set => _dbName = string.IsNullOrWhiteSpace(value) ? _dbName : value.Trim(); }
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("User")]
|
||||||
|
public string DbUser { get => _dbUser; set => _dbUser = string.IsNullOrWhiteSpace(value) ? _dbUser : value.Trim(); }
|
||||||
|
|
||||||
|
[Category("Database")]
|
||||||
|
[DisplayName("Password")]
|
||||||
|
[PasswordPropertyText(true)]
|
||||||
|
public string DbPassword { get => _dbPassword; set => _dbPassword = string.IsNullOrWhiteSpace(value) ? _dbPassword : value.Trim(); }
|
||||||
|
|
||||||
|
[Browsable(false)]
|
||||||
|
public string ConnectionString
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var builder = new MySqlConnector.MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = DbServer?.Trim(),
|
||||||
|
Database = DbName?.Trim(),
|
||||||
|
UserID = DbUser?.Trim(),
|
||||||
|
Password = DbPassword?.Trim(),
|
||||||
|
AllowPublicKeyRetrieval = true,
|
||||||
|
SslMode = MySqlConnector.MySqlSslMode.None,
|
||||||
|
Pooling = true,
|
||||||
|
MinimumPoolSize = 0,
|
||||||
|
MaximumPoolSize = 100
|
||||||
|
};
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppSettings Load()
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var settings = new AppSettings();
|
||||||
|
settings.Save();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(filePath);
|
||||||
|
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new AppSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, FileName);
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
var json = JsonSerializer.Serialize(this, options);
|
||||||
|
File.WriteAllText(filePath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
partial class MainForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && (components != null))
|
||||||
|
{
|
||||||
|
components.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Windows Form Designer generated code
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
toolStrip1 = new ToolStrip();
|
||||||
|
btn_serverstart = new ToolStripButton();
|
||||||
|
btn_localWebserver = new ToolStripButton();
|
||||||
|
statusStrip1 = new StatusStrip();
|
||||||
|
label_apiRatelimit = new ToolStripStatusLabel();
|
||||||
|
label_buildVersion = new ToolStripStatusLabel();
|
||||||
|
label_dbSize = new ToolStripStatusLabel();
|
||||||
|
tabControl1 = new TabControl();
|
||||||
|
tabPage_terminal = new TabPage();
|
||||||
|
rtb_terminal = new RichTextBox();
|
||||||
|
tabPage2 = new TabPage();
|
||||||
|
pg_settings = new PropertyGrid();
|
||||||
|
menuStrip1 = new MenuStrip();
|
||||||
|
filesToolStripMenuItem = new ToolStripMenuItem();
|
||||||
|
editToolStripMenuItem = new ToolStripMenuItem();
|
||||||
|
btn_logfolder = new ToolStripMenuItem();
|
||||||
|
btn_openbrowser = new ToolStripMenuItem();
|
||||||
|
developmentToolStripMenuItem = new ToolStripMenuItem();
|
||||||
|
btn_dbReset = new ToolStripMenuItem();
|
||||||
|
btn_syncmarkets = new ToolStripMenuItem();
|
||||||
|
btn_dbUpdate = new ToolStripMenuItem();
|
||||||
|
btn_recalcAll = new ToolStripMenuItem();
|
||||||
|
toolStrip1.SuspendLayout();
|
||||||
|
statusStrip1.SuspendLayout();
|
||||||
|
tabControl1.SuspendLayout();
|
||||||
|
tabPage_terminal.SuspendLayout();
|
||||||
|
tabPage2.SuspendLayout();
|
||||||
|
menuStrip1.SuspendLayout();
|
||||||
|
SuspendLayout();
|
||||||
|
//
|
||||||
|
// toolStrip1
|
||||||
|
//
|
||||||
|
toolStrip1.ImageScalingSize = new Size(24, 24);
|
||||||
|
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver });
|
||||||
|
toolStrip1.Location = new Point(0, 33);
|
||||||
|
toolStrip1.Name = "toolStrip1";
|
||||||
|
toolStrip1.Size = new Size(1864, 34);
|
||||||
|
toolStrip1.TabIndex = 0;
|
||||||
|
//
|
||||||
|
// btn_serverstart
|
||||||
|
//
|
||||||
|
btn_serverstart.ImageTransparentColor = Color.Magenta;
|
||||||
|
btn_serverstart.Name = "btn_serverstart";
|
||||||
|
btn_serverstart.Size = new Size(127, 29);
|
||||||
|
btn_serverstart.Text = "▶ Start Server";
|
||||||
|
//
|
||||||
|
// btn_localWebserver
|
||||||
|
//
|
||||||
|
btn_localWebserver.ImageTransparentColor = Color.Magenta;
|
||||||
|
btn_localWebserver.Name = "btn_localWebserver";
|
||||||
|
btn_localWebserver.Size = new Size(161, 29);
|
||||||
|
btn_localWebserver.Text = "▶ Start Webserver";
|
||||||
|
//
|
||||||
|
// statusStrip1
|
||||||
|
//
|
||||||
|
statusStrip1.ImageScalingSize = new Size(24, 24);
|
||||||
|
statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_dbSize, label_buildVersion });
|
||||||
|
statusStrip1.Location = new Point(0, 1000);
|
||||||
|
statusStrip1.Name = "statusStrip1";
|
||||||
|
statusStrip1.Size = new Size(1864, 32);
|
||||||
|
statusStrip1.TabIndex = 1;
|
||||||
|
//
|
||||||
|
// label_apiRatelimit
|
||||||
|
//
|
||||||
|
label_apiRatelimit.Name = "label_apiRatelimit";
|
||||||
|
label_apiRatelimit.Size = new Size(1600, 25);
|
||||||
|
label_apiRatelimit.Spring = true;
|
||||||
|
label_apiRatelimit.Text = "API: OK";
|
||||||
|
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
|
||||||
|
//
|
||||||
|
// label_buildVersion
|
||||||
|
//
|
||||||
|
label_buildVersion.Name = "label_buildVersion";
|
||||||
|
label_buildVersion.Size = new Size(67, 25);
|
||||||
|
label_buildVersion.Text = "Build: -";
|
||||||
|
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
|
||||||
|
//
|
||||||
|
// label_dbSize
|
||||||
|
//
|
||||||
|
label_dbSize.Name = "label_dbSize";
|
||||||
|
label_dbSize.Size = new Size(150, 25);
|
||||||
|
label_dbSize.Text = "DB Size: -";
|
||||||
|
label_dbSize.TextAlign = ContentAlignment.MiddleRight;
|
||||||
|
//
|
||||||
|
// tabControl1
|
||||||
|
//
|
||||||
|
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||||
|
tabControl1.Controls.Add(tabPage_terminal);
|
||||||
|
tabControl1.Controls.Add(tabPage2);
|
||||||
|
tabControl1.Location = new Point(0, 61);
|
||||||
|
tabControl1.Name = "tabControl1";
|
||||||
|
tabControl1.SelectedIndex = 0;
|
||||||
|
tabControl1.Size = new Size(1864, 946);
|
||||||
|
tabControl1.TabIndex = 2;
|
||||||
|
//
|
||||||
|
// tabPage_terminal
|
||||||
|
//
|
||||||
|
tabPage_terminal.Controls.Add(rtb_terminal);
|
||||||
|
tabPage_terminal.Location = new Point(4, 34);
|
||||||
|
tabPage_terminal.Name = "tabPage_terminal";
|
||||||
|
tabPage_terminal.Padding = new Padding(3);
|
||||||
|
tabPage_terminal.Size = new Size(1856, 908);
|
||||||
|
tabPage_terminal.TabIndex = 0;
|
||||||
|
tabPage_terminal.Text = "Terminal";
|
||||||
|
tabPage_terminal.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// rtb_terminal
|
||||||
|
//
|
||||||
|
rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||||
|
rtb_terminal.Location = new Point(3, 6);
|
||||||
|
rtb_terminal.Name = "rtb_terminal";
|
||||||
|
rtb_terminal.Size = new Size(1847, 896);
|
||||||
|
rtb_terminal.TabIndex = 0;
|
||||||
|
rtb_terminal.Text = "";
|
||||||
|
//
|
||||||
|
// tabPage2
|
||||||
|
//
|
||||||
|
tabPage2.Controls.Add(pg_settings);
|
||||||
|
tabPage2.Location = new Point(4, 34);
|
||||||
|
tabPage2.Name = "tabPage2";
|
||||||
|
tabPage2.Padding = new Padding(3);
|
||||||
|
tabPage2.Size = new Size(1856, 908);
|
||||||
|
tabPage2.TabIndex = 1;
|
||||||
|
tabPage2.Text = "Settings";
|
||||||
|
tabPage2.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// pg_settings
|
||||||
|
//
|
||||||
|
pg_settings.Location = new Point(3, 6);
|
||||||
|
pg_settings.Name = "pg_settings";
|
||||||
|
pg_settings.Size = new Size(1850, 896);
|
||||||
|
pg_settings.TabIndex = 0;
|
||||||
|
//
|
||||||
|
// menuStrip1
|
||||||
|
//
|
||||||
|
menuStrip1.ImageScalingSize = new Size(24, 24);
|
||||||
|
menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem });
|
||||||
|
menuStrip1.Location = new Point(0, 0);
|
||||||
|
menuStrip1.Name = "menuStrip1";
|
||||||
|
menuStrip1.Size = new Size(1864, 33);
|
||||||
|
menuStrip1.TabIndex = 3;
|
||||||
|
//
|
||||||
|
// filesToolStripMenuItem
|
||||||
|
//
|
||||||
|
filesToolStripMenuItem.Name = "filesToolStripMenuItem";
|
||||||
|
filesToolStripMenuItem.Size = new Size(62, 29);
|
||||||
|
filesToolStripMenuItem.Text = "Files";
|
||||||
|
//
|
||||||
|
// editToolStripMenuItem
|
||||||
|
//
|
||||||
|
editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser });
|
||||||
|
editToolStripMenuItem.Name = "editToolStripMenuItem";
|
||||||
|
editToolStripMenuItem.Size = new Size(58, 29);
|
||||||
|
editToolStripMenuItem.Text = "Edit";
|
||||||
|
//
|
||||||
|
// btn_logfolder
|
||||||
|
//
|
||||||
|
btn_logfolder.Name = "btn_logfolder";
|
||||||
|
btn_logfolder.Size = new Size(261, 34);
|
||||||
|
btn_logfolder.Text = "Show Logfolder";
|
||||||
|
btn_logfolder.Click += btn_logfolder_Click;
|
||||||
|
//
|
||||||
|
// btn_openbrowser
|
||||||
|
//
|
||||||
|
btn_openbrowser.Name = "btn_openbrowser";
|
||||||
|
btn_openbrowser.Size = new Size(261, 34);
|
||||||
|
btn_openbrowser.Text = "Show Local WebUI";
|
||||||
|
btn_openbrowser.Click += btn_openbrowser_Click;
|
||||||
|
//
|
||||||
|
// developmentToolStripMenuItem
|
||||||
|
//
|
||||||
|
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets, btn_dbUpdate, btn_recalcAll });
|
||||||
|
developmentToolStripMenuItem.Name = "developmentToolStripMenuItem";
|
||||||
|
developmentToolStripMenuItem.Size = new Size(135, 29);
|
||||||
|
developmentToolStripMenuItem.Text = "Development";
|
||||||
|
//
|
||||||
|
// btn_dbReset
|
||||||
|
//
|
||||||
|
btn_dbReset.Name = "btn_dbReset";
|
||||||
|
btn_dbReset.Size = new Size(286, 34);
|
||||||
|
btn_dbReset.Text = "reset TradesDB";
|
||||||
|
//
|
||||||
|
// btn_syncmarkets
|
||||||
|
//
|
||||||
|
btn_syncmarkets.Name = "btn_syncmarkets";
|
||||||
|
btn_syncmarkets.Size = new Size(286, 34);
|
||||||
|
btn_syncmarkets.Text = "Sync Markets";
|
||||||
|
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
|
||||||
|
//
|
||||||
|
// btn_dbUpdate
|
||||||
|
//
|
||||||
|
btn_dbUpdate.Name = "btn_dbUpdate";
|
||||||
|
btn_dbUpdate.Size = new Size(286, 34);
|
||||||
|
btn_dbUpdate.Text = "UpdateDB";
|
||||||
|
btn_dbUpdate.Click += btn_dbUpdate_Click;
|
||||||
|
//
|
||||||
|
// btn_recalcAll
|
||||||
|
//
|
||||||
|
btn_recalcAll.Name = "btn_recalcAll";
|
||||||
|
btn_recalcAll.Size = new Size(286, 34);
|
||||||
|
btn_recalcAll.Text = "Recalculate All Traders";
|
||||||
|
btn_recalcAll.Click += btn_recalcAll_Click;
|
||||||
|
//
|
||||||
|
// MainForm
|
||||||
|
//
|
||||||
|
AutoScaleDimensions = new SizeF(10F, 25F);
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(1864, 1032);
|
||||||
|
Controls.Add(tabControl1);
|
||||||
|
Controls.Add(statusStrip1);
|
||||||
|
Controls.Add(toolStrip1);
|
||||||
|
Controls.Add(menuStrip1);
|
||||||
|
MainMenuStrip = menuStrip1;
|
||||||
|
MaximumSize = new Size(1886, 1088);
|
||||||
|
MinimumSize = new Size(1886, 1088);
|
||||||
|
Name = "MainForm";
|
||||||
|
Text = "Predictalytics";
|
||||||
|
toolStrip1.ResumeLayout(false);
|
||||||
|
toolStrip1.PerformLayout();
|
||||||
|
statusStrip1.ResumeLayout(false);
|
||||||
|
statusStrip1.PerformLayout();
|
||||||
|
tabControl1.ResumeLayout(false);
|
||||||
|
tabPage_terminal.ResumeLayout(false);
|
||||||
|
tabPage2.ResumeLayout(false);
|
||||||
|
menuStrip1.ResumeLayout(false);
|
||||||
|
menuStrip1.PerformLayout();
|
||||||
|
ResumeLayout(false);
|
||||||
|
PerformLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private ToolStrip toolStrip1;
|
||||||
|
private ToolStripButton btn_serverstart;
|
||||||
|
private ToolStripButton btn_localWebserver;
|
||||||
|
private StatusStrip statusStrip1;
|
||||||
|
private TabControl tabControl1;
|
||||||
|
private TabPage tabPage_terminal;
|
||||||
|
private RichTextBox rtb_terminal;
|
||||||
|
private TabPage tabPage2;
|
||||||
|
private MenuStrip menuStrip1;
|
||||||
|
private ToolStripMenuItem filesToolStripMenuItem;
|
||||||
|
private ToolStripMenuItem editToolStripMenuItem;
|
||||||
|
private ToolStripMenuItem btn_logfolder;
|
||||||
|
private ToolStripMenuItem btn_openbrowser;
|
||||||
|
private ToolStripMenuItem developmentToolStripMenuItem;
|
||||||
|
private ToolStripMenuItem btn_dbReset;
|
||||||
|
private ToolStripMenuItem btn_syncmarkets;
|
||||||
|
private PropertyGrid pg_settings;
|
||||||
|
private ToolStripStatusLabel label_apiRatelimit;
|
||||||
|
private ToolStripStatusLabel label_dbSize;
|
||||||
|
private ToolStripStatusLabel label_buildVersion;
|
||||||
|
private ToolStripMenuItem btn_dbUpdate;
|
||||||
|
private ToolStripMenuItem btn_recalcAll;
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Predictalytics.WinFormsHost.Services;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
public partial class MainForm : Form
|
||||||
|
{
|
||||||
|
private EmbeddedWebServer? _webServer;
|
||||||
|
private CancellationTokenSource? _workerCts;
|
||||||
|
private bool _workerRunning;
|
||||||
|
private bool _webServerRunning;
|
||||||
|
private AppSettings _settings = null!;
|
||||||
|
private WatchdogHeartbeatService? _watchdog;
|
||||||
|
|
||||||
|
/// <summary>Exposes the terminal RichTextBox for the Serilog sink.</summary>
|
||||||
|
public RichTextBox Terminal => rtb_terminal;
|
||||||
|
|
||||||
|
public MainForm()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
this.Text = "Predictalytics Analytics — Backend Server";
|
||||||
|
rtb_terminal.BackColor = System.Drawing.Color.FromArgb(15, 15, 20);
|
||||||
|
rtb_terminal.ForeColor = System.Drawing.Color.FromArgb(180, 180, 180);
|
||||||
|
rtb_terminal.Font = new Font("Cascadia Code", 9.5f, FontStyle.Regular);
|
||||||
|
rtb_terminal.ReadOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called after Serilog is configured. Initializes the embedded web server.
|
||||||
|
/// </summary>
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
_settings = AppSettings.Load();
|
||||||
|
pg_settings.SelectedObject = _settings;
|
||||||
|
pg_settings.PropertyValueChanged += (s, e) => {
|
||||||
|
_settings.Save();
|
||||||
|
if (_webServer != null)
|
||||||
|
{
|
||||||
|
_webServer.ConnectionString = _settings.ConnectionString;
|
||||||
|
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||||
|
_webServer.EgressChannelsText = _settings.EgressChannelsText;
|
||||||
|
}
|
||||||
|
RestartWatchdog();
|
||||||
|
};
|
||||||
|
|
||||||
|
_webServer = new EmbeddedWebServer();
|
||||||
|
_webServer.ConnectionString = _settings.ConnectionString;
|
||||||
|
_webServer.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||||
|
_webServer.EgressChannelsText = _settings.EgressChannelsText;
|
||||||
|
|
||||||
|
// Build Version (Date of compilation/file creation)
|
||||||
|
try {
|
||||||
|
var buildDate = new FileInfo(this.GetType().Assembly.Location).LastWriteTime;
|
||||||
|
label_buildVersion.Text = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
|
||||||
|
} catch {
|
||||||
|
label_buildVersion.Text = "Build: Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateStatusBar();
|
||||||
|
|
||||||
|
// Wire up button events
|
||||||
|
btn_serverstart.Click += Btn_serverstart_Click;
|
||||||
|
btn_localWebserver.Click += Btn_localWebserver_Click;
|
||||||
|
|
||||||
|
Log.Information("MainForm initialized. Ready.");
|
||||||
|
Log.Information("Press 'Start Server' to begin polling & discovery.");
|
||||||
|
Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort);
|
||||||
|
|
||||||
|
_ = UpdateDbSizeAsync();
|
||||||
|
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
|
||||||
|
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
|
||||||
|
dbSizeTimer.Start();
|
||||||
|
|
||||||
|
RestartWatchdog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// (Re-)creates the Watchdog heartbeat sender from the current settings.
|
||||||
|
/// Called at startup and whenever settings change.
|
||||||
|
/// </summary>
|
||||||
|
private void RestartWatchdog()
|
||||||
|
{
|
||||||
|
_watchdog?.Dispose();
|
||||||
|
_watchdog = null;
|
||||||
|
|
||||||
|
if (!_settings.WatchdogEnabled) return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_settings.WatchdogApiKey) || string.IsNullOrWhiteSpace(_settings.WatchdogUrl))
|
||||||
|
{
|
||||||
|
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Settings eintragen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchdog = new WatchdogHeartbeatService(
|
||||||
|
_settings.WatchdogUrl,
|
||||||
|
_settings.WatchdogApiKey,
|
||||||
|
_settings.WatchdogSource,
|
||||||
|
_settings.WatchdogInstance,
|
||||||
|
_settings.WatchdogIntervalSeconds,
|
||||||
|
metadataProvider: () => new
|
||||||
|
{
|
||||||
|
workersRunning = _workerRunning,
|
||||||
|
webserverRunning = _webServerRunning
|
||||||
|
});
|
||||||
|
_watchdog.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Btn_serverstart_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (!_workerRunning)
|
||||||
|
{
|
||||||
|
// Start workers
|
||||||
|
_workerCts = new CancellationTokenSource();
|
||||||
|
_workerRunning = true;
|
||||||
|
btn_serverstart.Text = "⏹ Stop Server";
|
||||||
|
Log.Information("🚀 Starting background workers...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_webServer!.ConnectionString = _settings.ConnectionString;
|
||||||
|
_webServer!.DbConnectionDebug = _settings.DbConnectionDebug;
|
||||||
|
_webServer!.EgressChannelsText = _settings.EgressChannelsText;
|
||||||
|
await _webServer!.StartWorkersAsync(_workerCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "Worker error"); }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Stop workers
|
||||||
|
Log.Information("⏹ Stopping background workers...");
|
||||||
|
_workerCts?.Cancel();
|
||||||
|
_workerRunning = false;
|
||||||
|
btn_serverstart.Text = "▶ Start Server";
|
||||||
|
Log.Information("Workers stopped.");
|
||||||
|
}
|
||||||
|
UpdateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (!_webServerRunning)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Information("🌐 Starting embedded Kestrel webserver on http://localhost:{Port}...", _settings.WebserverPort);
|
||||||
|
await _webServer!.StartWebServerAsync(_settings.WebserverPort);
|
||||||
|
_webServerRunning = true;
|
||||||
|
btn_localWebserver.Text = "⏹ Stop Webserver";
|
||||||
|
Log.Information("✅ WebUI available at http://localhost:{Port}", _settings.WebserverPort);
|
||||||
|
Log.Information("📄 Swagger API docs at http://localhost:{Port}/swagger", _settings.WebserverPort);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to start webserver");
|
||||||
|
_webServerRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("⏹ Stopping webserver...");
|
||||||
|
await _webServer!.StopWebServerAsync();
|
||||||
|
_webServerRunning = false;
|
||||||
|
btn_localWebserver.Text = "▶ Start Webserver";
|
||||||
|
Log.Information("Webserver stopped.");
|
||||||
|
}
|
||||||
|
UpdateStatusBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatusBar()
|
||||||
|
{
|
||||||
|
var workerStatus = _workerRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
|
||||||
|
var serverStatus = _webServerRunning ? $"[RUNNING] Webserver :{_settings.WebserverPort}" : "[STOPPED] Webserver";
|
||||||
|
this.Text = $"Predictalytics Analytics — {workerStatus} | {serverStatus}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||||
|
{
|
||||||
|
_watchdog?.NotifyStopping();
|
||||||
|
_watchdog?.Dispose();
|
||||||
|
_watchdog = null;
|
||||||
|
_workerCts?.Cancel();
|
||||||
|
_webServer?.StopWebServerAsync().GetAwaiter().GetResult();
|
||||||
|
base.OnFormClosing(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void btn_openbrowser_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", $"\"http://localhost:{_settings.WebserverPort}\"");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "Fehler beim Öffnen des Browsers");
|
||||||
|
MessageBox.Show("Browser konnte nicht gestartet werden.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void btn_logfolder_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs");
|
||||||
|
if (Directory.Exists(logPath))
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", logPath);
|
||||||
|
else
|
||||||
|
System.Diagnostics.Process.Start("explorer.exe", Environment.CurrentDirectory);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void syncMarketsaToolStripMenuItem_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_workerRunning)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Market sync cannot be started while background workers are running.",
|
||||||
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
btn_syncmarkets.Enabled = false;
|
||||||
|
Log.Information("Manual market sync triggered...");
|
||||||
|
|
||||||
|
// Use a temporary CTS for this operation
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await _webServer!.RunSingleMarketSyncAsync(cts.Token);
|
||||||
|
|
||||||
|
Log.Information("Manual market sync completed successfully.");
|
||||||
|
MessageBox.Show("Market sync completed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Manual market sync failed");
|
||||||
|
MessageBox.Show($"Error syncing markets: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
btn_syncmarkets.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void btn_dbUpdate_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_workerRunning)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Database update cannot be run while background workers are running.",
|
||||||
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
btn_dbUpdate.Enabled = false;
|
||||||
|
Log.Information("Manual database update triggered...");
|
||||||
|
await _webServer!.UpdateDatabaseAsync();
|
||||||
|
Log.Information("Database updated successfully.");
|
||||||
|
MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Manual database update failed");
|
||||||
|
MessageBox.Show($"Database update failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
btn_dbUpdate.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void btn_recalcAll_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_workerRunning)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Recalculation cannot be started while background workers are running. Stop the server first.",
|
||||||
|
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm = MessageBox.Show(
|
||||||
|
"This deletes all DERIVED analytics data (positions, daily snapshots, category stats) " +
|
||||||
|
"and marks every trader for full recalculation.\n\n" +
|
||||||
|
"Raw trades and markets are NOT touched.\n\n" +
|
||||||
|
"After this, start the server: the analytics worker rebuilds every trader with the current " +
|
||||||
|
"engine (runs in the background, can take several hours for large trader counts).\n\nContinue?",
|
||||||
|
"Recalculate All Traders", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
|
||||||
|
if (confirm != DialogResult.Yes) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
btn_recalcAll.Enabled = false;
|
||||||
|
Log.Information("Manual full recalculation reset triggered...");
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var summary = await _webServer!.RunRecalculateAllTradersAsync(cts.Token);
|
||||||
|
|
||||||
|
MessageBox.Show($"Reset complete:\n\n{summary}\n\nNow start the server to rebuild the analytics.",
|
||||||
|
"Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Full recalculation reset failed");
|
||||||
|
MessageBox.Show($"Recalculation reset failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
btn_recalcAll.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateDbSizeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build the connection string
|
||||||
|
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(_settings.ConnectionString);
|
||||||
|
if (string.IsNullOrWhiteSpace(csBuilder.Database))
|
||||||
|
return; // Not ready or valid yet
|
||||||
|
|
||||||
|
using var conn = new MySqlConnector.MySqlConnection(_settings.ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();";
|
||||||
|
|
||||||
|
var result = await cmd.ExecuteScalarAsync();
|
||||||
|
if (result != DBNull.Value && result != null)
|
||||||
|
{
|
||||||
|
var sizeMb = Convert.ToDouble(result);
|
||||||
|
this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.Invoke(() => label_dbSize.Text = "DB Size: Error");
|
||||||
|
Log.Debug(ex, "Failed to fetch DB size for status bar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<metadata name="toolStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>17, 17</value>
|
||||||
|
</metadata>
|
||||||
|
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>162, 17</value>
|
||||||
|
</metadata>
|
||||||
|
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>322, 17</value>
|
||||||
|
</metadata>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<RootNamespace>Predictalytics.WinFormsHost</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
|
||||||
|
<ApplicationVisualStyles>true</ApplicationVisualStyles>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
|
||||||
|
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
|
||||||
|
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
|
||||||
|
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CleanupLocalization" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<LanguageFolders Include="$(TargetDir)cs;$(TargetDir)de;$(TargetDir)es;$(TargetDir)fr;$(TargetDir)it;$(TargetDir)ja;$(TargetDir)ko;$(TargetDir)pl;$(TargetDir)pt-BR;$(TargetDir)ru;$(TargetDir)tr;$(TargetDir)zh-Hans;$(TargetDir)zh-Hant" />
|
||||||
|
</ItemGroup>
|
||||||
|
<RemoveDir Directories="@(LanguageFolders)" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
+61
-81
@@ -1,61 +1,38 @@
|
|||||||
using Predictalytics.Infrastructure.Logging;
|
using Predictalytics.Infrastructure.Logging;
|
||||||
|
using Predictalytics.WinFormsHost.Services;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
/// <summary>
|
internal static class Program
|
||||||
/// Serilog-Konfiguration der Anwendung. Plattformneutral: der optionale
|
|
||||||
/// Terminal-Sink bekommt lediglich eine Schreib-Action, das Marshalling auf den
|
|
||||||
/// UI-Thread liegt beim jeweiligen Host.
|
|
||||||
/// </summary>
|
|
||||||
public static class LoggingSetup
|
|
||||||
{
|
{
|
||||||
private const string TextTemplate =
|
[STAThread]
|
||||||
"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
|
static void Main()
|
||||||
|
|
||||||
private const string SimpleTemplate =
|
|
||||||
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
|
||||||
|
|
||||||
/// <summary>Umgebungsvariable, mit der das Logverzeichnis festgelegt werden kann.</summary>
|
|
||||||
public const string LogDirectoryVariable = "PREDICTALYTICS_LOG_DIR";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Standard-Logverzeichnis: neben den Einstellungen, also
|
|
||||||
/// <c>%APPDATA%\Predictalytics\logs</c> bzw. <c>~/.config/Predictalytics/logs</c>.
|
|
||||||
/// <para>
|
|
||||||
/// Bewusst nicht mehr neben der Programmdatei: deren Verzeichnis ist unter Linux
|
|
||||||
/// ueblicherweise nicht beschreibbar, und beim Update wuerde es ueberschrieben.
|
|
||||||
/// Ueber <see cref="LogDirectoryVariable"/> uebersteuerbar — etwa auf
|
|
||||||
/// <c>/var/log/predictalytics</c> im systemd-Betrieb.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static string DefaultLogDirectory
|
|
||||||
{
|
{
|
||||||
get
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
|
// ─── License gate: no usable license, no app ───
|
||||||
|
var licenseClient = LicenseGuard.EnsureLicensed();
|
||||||
|
if (licenseClient == null)
|
||||||
{
|
{
|
||||||
var configured = Environment.GetEnvironmentVariable(LogDirectoryVariable);
|
return;
|
||||||
return string.IsNullOrWhiteSpace(configured)
|
|
||||||
? Path.Combine(PredictalyticsOptions.SettingsDirectory, "logs")
|
|
||||||
: configured;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
var mainForm = new MainForm();
|
||||||
/// Richtet den globalen Serilog-Logger ein.
|
var rtbWriteAction = TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm);
|
||||||
/// </summary>
|
|
||||||
/// <param name="terminalSink">
|
|
||||||
/// Optionale Schreib-Action fuer eine Terminalanzeige im Host. Bekommt die
|
|
||||||
/// fertig formatierte Zeile und den Level; muss selbst auf den UI-Thread wechseln.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="logDirectory">Basisverzeichnis der Logdateien. Standard: <see cref="DefaultLogDirectory"/>.</param>
|
|
||||||
public static void Configure(
|
|
||||||
Action<string, LogEventLevel>? terminalSink = null,
|
|
||||||
string? logDirectory = null)
|
|
||||||
{
|
|
||||||
var logBaseDir = logDirectory ?? DefaultLogDirectory;
|
|
||||||
|
|
||||||
var config = new LoggerConfiguration()
|
// ─── Output template matching terminal format ───
|
||||||
|
const string textTemplate =
|
||||||
|
"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
const string simpleTemplate =
|
||||||
|
"[{Timestamp:HH:mm:ss}] [{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
// ─── Log directory ───
|
||||||
|
var logBaseDir = Path.Combine(AppContext.BaseDirectory, "logs");
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Information()
|
.MinimumLevel.Information()
|
||||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||||
@@ -66,20 +43,11 @@ public static class LoggingSetup
|
|||||||
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
|
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
|
||||||
|
|
||||||
// ── Console (simple) ──
|
// ── Console (simple) ──
|
||||||
.WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
|
.WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
|
||||||
|
|
||||||
// ── Deployment Center error stream (Error/Fatal) ──
|
// ── RichTextBox Terminal ──
|
||||||
// Wird hier registriert, lange bevor die Einstellungen bekannt sind: der Sink
|
.WriteTo.Sink(new RichTextBoxSink(rtbWriteAction), restrictedToMinimumLevel: LogEventLevel.Warning)
|
||||||
// fragt DcErrorReporter zur Sendezeit, ob das Melden eingeschaltet ist.
|
|
||||||
.WriteTo.Sink(new DcErrorSink(), restrictedToMinimumLevel: LogEventLevel.Error);
|
|
||||||
|
|
||||||
// ── Terminalanzeige des Hosts (optional) ──
|
|
||||||
if (terminalSink != null)
|
|
||||||
{
|
|
||||||
config = config.WriteTo.Sink(new DelegateSink(terminalSink), restrictedToMinimumLevel: LogEventLevel.Warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
config = config
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// FILE SINKS — By Level
|
// FILE SINKS — By Level
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
@@ -88,7 +56,7 @@ public static class LoggingSetup
|
|||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "all", "all-.log"),
|
Path.Combine(logBaseDir, "all", "all-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
fileSizeLimitBytes: 50_000_000,
|
fileSizeLimitBytes: 50_000_000,
|
||||||
shared: true)
|
shared: true)
|
||||||
@@ -99,7 +67,7 @@ public static class LoggingSetup
|
|||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "info", "info-.log"),
|
Path.Combine(logBaseDir, "info", "info-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 14,
|
retainedFileCountLimit: 14,
|
||||||
shared: true))
|
shared: true))
|
||||||
|
|
||||||
@@ -109,7 +77,7 @@ public static class LoggingSetup
|
|||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "warning", "warning-.log"),
|
Path.Combine(logBaseDir, "warning", "warning-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
shared: true))
|
shared: true))
|
||||||
|
|
||||||
@@ -119,37 +87,47 @@ public static class LoggingSetup
|
|||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "error", "error-.log"),
|
Path.Combine(logBaseDir, "error", "error-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 60,
|
retainedFileCountLimit: 60,
|
||||||
shared: true))
|
shared: true))
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// FILE SINKS — By Platform
|
// FILE SINKS — By Platform
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Polymarket
|
||||||
.WriteTo.Logger(lc => lc
|
.WriteTo.Logger(lc => lc
|
||||||
.Filter.ByIncludingOnly(e => HasPlatform(e, "Polymarket"))
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("Platform") &&
|
||||||
|
e.Properties["Platform"].ToString().Contains("Polymarket"))
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "platforms", "polymarket-.log"),
|
Path.Combine(logBaseDir, "platforms", "polymarket-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
shared: true))
|
shared: true))
|
||||||
|
|
||||||
|
// Limitless
|
||||||
.WriteTo.Logger(lc => lc
|
.WriteTo.Logger(lc => lc
|
||||||
.Filter.ByIncludingOnly(e => HasPlatform(e, "Limitless"))
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("Platform") &&
|
||||||
|
e.Properties["Platform"].ToString().Contains("Limitless"))
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "platforms", "limitless-.log"),
|
Path.Combine(logBaseDir, "platforms", "limitless-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
shared: true))
|
shared: true))
|
||||||
|
|
||||||
|
// Azuro
|
||||||
.WriteTo.Logger(lc => lc
|
.WriteTo.Logger(lc => lc
|
||||||
.Filter.ByIncludingOnly(e => HasPlatform(e, "Azuro"))
|
.Filter.ByIncludingOnly(e =>
|
||||||
|
e.Properties.ContainsKey("Platform") &&
|
||||||
|
e.Properties["Platform"].ToString().Contains("Azuro"))
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "platforms", "azuro-.log"),
|
Path.Combine(logBaseDir, "platforms", "azuro-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
shared: true))
|
shared: true))
|
||||||
|
|
||||||
@@ -163,23 +141,25 @@ public static class LoggingSetup
|
|||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
Path.Combine(logBaseDir, "workers", "workers-.log"),
|
Path.Combine(logBaseDir, "workers", "workers-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
outputTemplate: TextTemplate,
|
outputTemplate: textTemplate,
|
||||||
retainedFileCountLimit: 14,
|
retainedFileCountLimit: 14,
|
||||||
shared: true));
|
shared: true))
|
||||||
|
|
||||||
Log.Logger = config.CreateLogger();
|
.CreateLogger();
|
||||||
}
|
|
||||||
|
|
||||||
private static bool HasPlatform(LogEvent e, string platform) =>
|
|
||||||
e.Properties.ContainsKey("Platform") &&
|
|
||||||
e.Properties["Platform"].ToString().Contains(platform);
|
|
||||||
|
|
||||||
/// <summary>Startbanner, identisch fuer alle Hosts.</summary>
|
|
||||||
public static void LogStartupBanner()
|
|
||||||
{
|
|
||||||
Log.Warning("══════════════════════════════════════════════════════");
|
Log.Warning("══════════════════════════════════════════════════════");
|
||||||
Log.Warning(" 🚀 Predictalytics v{Version} — Data retrieval started!", DcConfig.AppVersion);
|
Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!");
|
||||||
Log.Warning(" 📊 First platform report in 5 minutes.");
|
Log.Warning(" 📊 First platform report in 5 minutes.");
|
||||||
Log.Warning("══════════════════════════════════════════════════════");
|
Log.Warning("══════════════════════════════════════════════════════");
|
||||||
|
|
||||||
|
mainForm.Initialize();
|
||||||
|
|
||||||
|
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
|
||||||
|
using var licenseTimer = LicenseGuard.StartPeriodicRevalidation(licenseClient);
|
||||||
|
|
||||||
|
System.Windows.Forms.Application.Run(mainForm);
|
||||||
|
|
||||||
|
Log.Information("Application shutting down.");
|
||||||
|
Log.CloseAndFlush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Predictalytics.WinFormsHost": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:62271;http://localhost:62272"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-219
@@ -1,85 +1,36 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Predictalytics.Api;
|
using Predictalytics.Api;
|
||||||
|
using Predictalytics.Api.Endpoints;
|
||||||
using Predictalytics.Worker;
|
using Predictalytics.Worker;
|
||||||
using Predictalytics.Application.Interfaces;
|
using Predictalytics.Application.Interfaces;
|
||||||
using Predictalytics.Domain.Interfaces;
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace Predictalytics.Hosting;
|
namespace Predictalytics.WinFormsHost.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plattformneutraler Kern der Anwendung: Lebenszyklus des eingebetteten
|
/// Manages the lifecycle of the embedded Kestrel web server and background workers.
|
||||||
/// Kestrel-Webservers, der Hintergrund-Worker und der manuellen Wartungsaktionen.
|
|
||||||
/// <para>
|
|
||||||
/// Enthaelt bewusst keinerlei UI-Bezug — die Avalonia-Shell und der
|
|
||||||
/// Headless-Modus setzen gleichermassen darauf auf.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PredictalyticsHost
|
public class EmbeddedWebServer
|
||||||
{
|
{
|
||||||
private WebApplication? _app;
|
private WebApplication? _app;
|
||||||
private Task? _runTask;
|
private Task? _runTask;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
public string? ConnectionString { get; set; }
|
||||||
public PredictalyticsHost(PredictalyticsOptions options) => Options = options;
|
public bool DbConnectionDebug { get; set; }
|
||||||
|
public string? EgressChannelsText { get; set; }
|
||||||
/// <summary>Aktuelle Konfiguration. Aenderungen wirken beim naechsten Start.</summary>
|
|
||||||
public PredictalyticsOptions Options { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Wird gemeldet, wenn sich der Laufzustand aendert (fuer Statusanzeigen).</summary>
|
|
||||||
public event Action? StateChanged;
|
|
||||||
|
|
||||||
public bool WebServerRunning { get; private set; }
|
|
||||||
public bool WorkersRunning { get; private set; }
|
|
||||||
|
|
||||||
private void RaiseStateChanged() => StateChanged?.Invoke();
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
// Wartungsaktionen
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public async Task UpdateDatabaseAsync()
|
public async Task UpdateDatabaseAsync()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug);
|
||||||
services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
|
||||||
Options.ConnectionString, Options.DbConnectionDebug);
|
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, Options.DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Groesse der Datenbank in MB, oder null wenn nicht ermittelbar.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<double?> GetDatabaseSizeMbAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(Options.ConnectionString);
|
|
||||||
if (string.IsNullOrWhiteSpace(csBuilder.Database)) return null;
|
|
||||||
|
|
||||||
using var conn = new MySqlConnector.MySqlConnection(Options.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct);
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText =
|
|
||||||
"SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();";
|
|
||||||
|
|
||||||
var result = await cmd.ExecuteScalarAsync(ct);
|
|
||||||
if (result == null || result == DBNull.Value) return null;
|
|
||||||
return Convert.ToDouble(result);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Failed to fetch DB size");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -94,9 +45,7 @@ public sealed class PredictalyticsHost
|
|||||||
Log.Warning("🔄 Full trader recalculation reset requested...");
|
Log.Warning("🔄 Full trader recalculation reset requested...");
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug);
|
||||||
services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
|
||||||
Options.ConnectionString, Options.DbConnectionDebug);
|
|
||||||
using var provider = services.BuildServiceProvider();
|
using var provider = services.BuildServiceProvider();
|
||||||
using var scope = provider.CreateScope();
|
using var scope = provider.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
|
||||||
@@ -111,26 +60,21 @@ public sealed class PredictalyticsHost
|
|||||||
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct);
|
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct);
|
||||||
Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled);
|
Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled);
|
||||||
|
|
||||||
// 1b. Offline Category/Subcategory backfill: Kategorie aus den Event-Tags neu
|
// 1b. Offline Category/Subcategory backfill:
|
||||||
// ableiten, ohne einen einzigen API-Aufruf.
|
// Query all markets that have events with tags in the DB, and re-classify them
|
||||||
//
|
var dbMarkets = await db.Markets.Include(m => m.Event).ToListAsync(ct);
|
||||||
// Die Einschraenkung gehoert in die Abfrage, nicht in die Schleife: ohne sie holt
|
|
||||||
// der Aufruf den gesamten Marktbestand samt Event in den Speicher, um den Grossteil
|
|
||||||
// davon sofort wieder zu verwerfen. Das Ergebnis ist identisch - die Schleife hat
|
|
||||||
// schon vorher nur Maerkte mit gefuellten Event-Tags angefasst.
|
|
||||||
var dbMarkets = await db.Markets
|
|
||||||
.Include(m => m.Event)
|
|
||||||
.Where(m => m.Event != null && m.Event.Tags != null && m.Event.Tags != "")
|
|
||||||
.ToListAsync(ct);
|
|
||||||
int categoryBackfilledCount = 0;
|
int categoryBackfilledCount = 0;
|
||||||
foreach (var m in dbMarkets)
|
foreach (var m in dbMarkets)
|
||||||
{
|
{
|
||||||
var (newCategory, newSubcategory) = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(string.Empty, m.Event!.Tags, m.Question);
|
if (m.Event != null && !string.IsNullOrEmpty(m.Event.Tags))
|
||||||
if (m.Category != newCategory || m.Subcategory != newSubcategory)
|
|
||||||
{
|
{
|
||||||
m.Category = newCategory;
|
var (newCategory, newSubcategory) = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(string.Empty, m.Event.Tags, m.Question);
|
||||||
m.Subcategory = newSubcategory;
|
if (m.Category != newCategory || m.Subcategory != newSubcategory)
|
||||||
categoryBackfilledCount++;
|
{
|
||||||
|
m.Category = newCategory;
|
||||||
|
m.Subcategory = newSubcategory;
|
||||||
|
categoryBackfilledCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (categoryBackfilledCount > 0)
|
if (categoryBackfilledCount > 0)
|
||||||
@@ -166,11 +110,7 @@ public sealed class PredictalyticsHost
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
public async Task StartWebServerAsync(int port = 5000)
|
||||||
// Webserver
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public async Task StartWebServerAsync()
|
|
||||||
{
|
{
|
||||||
lock (_lock) { if (_app != null) return; }
|
lock (_lock) { if (_app != null) return; }
|
||||||
|
|
||||||
@@ -179,51 +119,40 @@ public sealed class PredictalyticsHost
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder();
|
var builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseUrls(Options.WebserverUrl);
|
builder.WebHost.UseUrls($"http://localhost:{port}");
|
||||||
|
|
||||||
var effectiveConnString = Options.ConnectionString;
|
string? effectiveConnString = ConnectionString;
|
||||||
if (!string.IsNullOrEmpty(effectiveConnString) &&
|
if (!string.IsNullOrEmpty(effectiveConnString) && (effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
|
||||||
(effectiveConnString.Contains("Database=;") || effectiveConnString.Contains("Database= ")))
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Connection string contains an empty 'Database' value.");
|
throw new InvalidOperationException("Connection string contains an empty 'Database' value.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}",
|
Log.Information("Initializing Predictalytics infrastructure with connection: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(effectiveConnString ?? "NULL", "Password=[^;]+", "Password=****"));
|
||||||
MaskPassword(effectiveConnString));
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(builder.Services, builder.Configuration, effectiveConnString, DbConnectionDebug);
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
|
||||||
builder.Services, builder.Configuration, effectiveConnString, Options.DbConnectionDebug);
|
|
||||||
|
|
||||||
ApplyEgressOptions(builder.Services);
|
if (!string.IsNullOrEmpty(EgressChannelsText))
|
||||||
|
{
|
||||||
|
var egressOptions = ParseEgressOptions(EgressChannelsText);
|
||||||
|
builder.Services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Channels = egressOptions.Channels;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1",
|
||||||
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
new() { Title = "Predictalytics Analytics API", Version = "v1" }));
|
||||||
|
|
||||||
// Die eigene Bind-URL ist immer erlaubt; zusaetzliche Herkuenfte koennen
|
var allowedOrigins = builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>()
|
||||||
// ueber appsettings.json ergaenzt werden.
|
?? new[] { "http://localhost:5000" };
|
||||||
var allowedOrigins = (builder.Configuration.GetSection("ApiSettings:AllowedOrigins").Get<string[]>() ?? [])
|
|
||||||
.Append(Options.WebserverUrl)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToArray();
|
|
||||||
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
|
||||||
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader()));
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseSwagger();
|
||||||
// Swagger legt die gesamte API-Oberflaeche offen — das ist im lokalen
|
app.UseSwaggerUI();
|
||||||
// Betrieb praktisch und bei einer Bindung ans Netz eine Preisgabe.
|
|
||||||
if (!Options.IsPubliclyBound)
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Information("Swagger ist deaktiviert, weil der Webserver über {Host} erreichbar ist.",
|
|
||||||
Options.WebserverHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
var wwwrootPath = FindWwwrootPath();
|
var wwwrootPath = FindWwwrootPath();
|
||||||
if (wwwrootPath != null)
|
if (wwwrootPath != null)
|
||||||
@@ -234,29 +163,26 @@ public sealed class PredictalyticsHost
|
|||||||
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
|
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
|
||||||
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
|
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
|
||||||
});
|
});
|
||||||
app.MapGet("/", () => Results.File(Path.Combine(wwwrootPath, "index.html"), "text/html"));
|
app.MapGet("/", () => Results.File(
|
||||||
|
Path.Combine(wwwrootPath, "index.html"), "text/html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
|
// Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
|
||||||
// Do NOT map endpoints individually here.
|
// Do NOT map endpoints individually here.
|
||||||
app.MapPredictalyticsReadEndpoints();
|
app.MapPredictalyticsReadEndpoints();
|
||||||
MapControlEndpointsGuarded(app);
|
app.MapPredictalyticsControlEndpoints();
|
||||||
|
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, Options.DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);
|
||||||
|
|
||||||
lock (_lock) { _app = app; }
|
lock (_lock) { _app = app; }
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
_runTask = app.RunAsync(_cts.Token);
|
_runTask = app.RunAsync(_cts.Token);
|
||||||
|
|
||||||
WebServerRunning = true;
|
Log.Information("Kestrel webserver started on port {Port}", port);
|
||||||
RaiseStateChanged();
|
|
||||||
Log.Information("Kestrel webserver started on {Url}", Options.WebserverUrl);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Error(ex, "Failed to start embedded web server");
|
Log.Error(ex, "Failed to start embedded web server");
|
||||||
WebServerRunning = false;
|
|
||||||
RaiseStateChanged();
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -272,50 +198,35 @@ public sealed class PredictalyticsHost
|
|||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
|
try { await app.StopAsync(TimeSpan.FromSeconds(5)); }
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
await ((IAsyncDisposable)app).DisposeAsync();
|
await (app as IAsyncDisposable).DisposeAsync();
|
||||||
Log.Information("Kestrel webserver stopped");
|
Log.Information("Kestrel webserver stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
WebServerRunning = false;
|
|
||||||
RaiseStateChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
// Worker
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Startet die Hintergrund-Worker und laeuft, bis <paramref name="ct"/> ausgeloest wird.
|
|
||||||
/// </summary>
|
|
||||||
public async Task StartWorkersAsync(CancellationToken ct)
|
public async Task StartWorkersAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Log.Information("Starting workers (no web server)...");
|
Log.Information("Starting workers (no web server)...");
|
||||||
WorkersRunning = true;
|
|
||||||
RaiseStateChanged();
|
|
||||||
|
|
||||||
try
|
var host = Host.CreateDefaultBuilder()
|
||||||
{
|
.UseSerilog()
|
||||||
var host = Host.CreateDefaultBuilder()
|
.ConfigureServices((ctx, services) =>
|
||||||
.UseSerilog()
|
{
|
||||||
.ConfigureServices((ctx, services) =>
|
Serilog.Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}", System.Text.RegularExpressions.Regex.Replace(ConnectionString ?? "NULL", "Password=[^;]+", "Password=****"));
|
||||||
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
|
||||||
|
if (!string.IsNullOrEmpty(EgressChannelsText))
|
||||||
{
|
{
|
||||||
Log.Warning("🔌 [StartWorkers] Using ConnectionString: {ConnectionString}",
|
var egressOptions = ParseEgressOptions(EgressChannelsText);
|
||||||
MaskPassword(Options.ConnectionString));
|
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(options =>
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
{
|
||||||
services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug);
|
options.Channels = egressOptions.Channels;
|
||||||
ApplyEgressOptions(services);
|
});
|
||||||
services.AddWorkerServices();
|
}
|
||||||
})
|
services.AddWorkerServices();
|
||||||
.Build();
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, Options.DbConnectionDebug);
|
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(host.Services, DbConnectionDebug);
|
||||||
await host.RunAsync(ct);
|
await host.RunAsync(ct);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
WorkersRunning = false;
|
|
||||||
RaiseStateChanged();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -329,8 +240,7 @@ public sealed class PredictalyticsHost
|
|||||||
.UseSerilog()
|
.UseSerilog()
|
||||||
.ConfigureServices((ctx, services) =>
|
.ConfigureServices((ctx, services) =>
|
||||||
{
|
{
|
||||||
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(
|
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, ctx.Configuration, ConnectionString, DbConnectionDebug);
|
||||||
services, ctx.Configuration, Options.ConnectionString, Options.DbConnectionDebug);
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -395,73 +305,19 @@ public sealed class PredictalyticsHost
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
// Intern
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entscheidet, ob und wie die schreibenden Endpunkte bereitgestellt werden.
|
|
||||||
/// <para>
|
|
||||||
/// Bei einer Bindung an localhost schuetzt das Betriebssystem — dort bleibt es beim
|
|
||||||
/// bisherigen Verhalten. Sobald der Dienst ueber das Netz erreichbar ist, gilt:
|
|
||||||
/// ohne Token keine Steuerbefehle. Bewusst so herum, damit eine unbedachte Umstellung
|
|
||||||
/// der Bind-Adresse nicht stillschweigend Jobs, Trader-Steuerung und die
|
|
||||||
/// Dev-Endpunkte oeffnet.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private void MapControlEndpointsGuarded(WebApplication app)
|
|
||||||
{
|
|
||||||
var token = Options.ApiControlToken?.Trim();
|
|
||||||
var hasToken = !string.IsNullOrWhiteSpace(token);
|
|
||||||
|
|
||||||
if (!Options.IsPubliclyBound)
|
|
||||||
{
|
|
||||||
app.MapPredictalyticsControlEndpoints(hasToken ? token : null);
|
|
||||||
Log.Information("Steuerendpunkte aktiv ({Protection}) — Bindung an {Host}.",
|
|
||||||
hasToken ? "tokengeschützt" : "ungeschützt, nur lokal erreichbar", Options.WebserverHost);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasToken)
|
|
||||||
{
|
|
||||||
Log.Error("🔒 Der Webserver ist an {Host} gebunden und damit über das Netz erreichbar, " +
|
|
||||||
"aber es ist kein API-Token gesetzt. Die schreibenden Endpunkte (Jobs, " +
|
|
||||||
"Trader-Steuerung, Dev) werden deshalb NICHT bereitgestellt. Token in den " +
|
|
||||||
"Einstellungen hinterlegen oder wieder an localhost binden.", Options.WebserverHost);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.MapPredictalyticsControlEndpoints(token);
|
|
||||||
Log.Warning("🔒 Webserver ist über {Host} erreichbar. Steuerendpunkte verlangen den Header {Header}.",
|
|
||||||
Options.WebserverHost, Predictalytics.Api.Security.ApiTokenFilter.HeaderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string MaskPassword(string? connectionString) =>
|
|
||||||
System.Text.RegularExpressions.Regex.Replace(connectionString ?? "NULL", "Password=[^;]+", "Password=****");
|
|
||||||
|
|
||||||
private void ApplyEgressOptions(IServiceCollection services)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(Options.EgressChannelsText)) return;
|
|
||||||
|
|
||||||
var egressOptions = ParseEgressOptions(Options.EgressChannelsText);
|
|
||||||
services.Configure<Predictalytics.Infrastructure.Configuration.EgressOptions>(o =>
|
|
||||||
{
|
|
||||||
o.Channels = egressOptions.Channels;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sucht das wwwroot-Verzeichnis. Im Deployment liegt es neben der Programmdatei
|
|
||||||
/// (siehe Content-Eintrag in der Host-csproj); der zweite Kandidat greift beim
|
|
||||||
/// Entwicklungslauf direkt aus bin/<config>/<tfm>/ heraus.
|
|
||||||
/// </summary>
|
|
||||||
private static string? FindWwwrootPath()
|
private static string? FindWwwrootPath()
|
||||||
{
|
{
|
||||||
var baseDir = AppContext.BaseDirectory;
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
// Try to find the src root by traversing up from the build output
|
||||||
|
// Typical: src/Predictalytics.WinFormsHost/bin/Debug/net8.0-windows/
|
||||||
var candidates = new[]
|
var candidates = new[]
|
||||||
{
|
{
|
||||||
Path.Combine(baseDir, "wwwroot"),
|
Path.Combine(baseDir, "wwwroot"),
|
||||||
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot"))
|
// From bin/Debug/net8.0-windows/ up to src/, then into Api/wwwroot
|
||||||
|
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "Predictalytics.Api", "wwwroot")),
|
||||||
|
Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "src", "Predictalytics.Api", "wwwroot")),
|
||||||
|
// Absolute fallback for this specific workspace
|
||||||
|
@"j:\Softwareprojekte\Predictalytics\Predictalytics\src\Predictalytics.Api\wwwroot"
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var path in candidates)
|
foreach (var path in candidates)
|
||||||
@@ -478,7 +334,7 @@ public sealed class PredictalyticsHost
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text)
|
private Predictalytics.Infrastructure.Configuration.EgressOptions ParseEgressOptions(string text)
|
||||||
{
|
{
|
||||||
var options = new Predictalytics.Infrastructure.Configuration.EgressOptions();
|
var options = new Predictalytics.Infrastructure.Configuration.EgressOptions();
|
||||||
if (string.IsNullOrWhiteSpace(text)) return options;
|
if (string.IsNullOrWhiteSpace(text)) return options;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using LicenseLabrador.Client;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal dialog shown at startup when no usable license is present.
|
||||||
|
/// Lets the user enter/activate a license key; closes with OK only after a
|
||||||
|
/// successful, checksum-verified validation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LicenseDialog : Form
|
||||||
|
{
|
||||||
|
private readonly LicenseClient _client;
|
||||||
|
private readonly Label _lblStatus;
|
||||||
|
private readonly TextBox _txtKey;
|
||||||
|
private readonly Button _btnActivate;
|
||||||
|
private readonly Button _btnExit;
|
||||||
|
|
||||||
|
public LicenseResult? Result { get; private set; }
|
||||||
|
|
||||||
|
public LicenseDialog(LicenseClient client, LicenseResult? lastResult)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
|
||||||
|
Text = "Predictalytics — Lizenzaktivierung";
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
ClientSize = new Size(460, 190);
|
||||||
|
|
||||||
|
var lblInfo = new Label
|
||||||
|
{
|
||||||
|
Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):",
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
Size = new Size(436, 34)
|
||||||
|
};
|
||||||
|
|
||||||
|
_txtKey = new TextBox
|
||||||
|
{
|
||||||
|
Location = new Point(12, 52),
|
||||||
|
Size = new Size(436, 26),
|
||||||
|
Font = new Font("Consolas", 11f),
|
||||||
|
CharacterCasing = CharacterCasing.Upper
|
||||||
|
};
|
||||||
|
|
||||||
|
_lblStatus = new Label
|
||||||
|
{
|
||||||
|
Location = new Point(12, 84),
|
||||||
|
Size = new Size(436, 50),
|
||||||
|
ForeColor = Color.Firebrick,
|
||||||
|
Text = FormatInitialStatus(lastResult)
|
||||||
|
};
|
||||||
|
|
||||||
|
_btnActivate = new Button
|
||||||
|
{
|
||||||
|
Text = "Aktivieren",
|
||||||
|
Location = new Point(252, 146),
|
||||||
|
Size = new Size(96, 30)
|
||||||
|
};
|
||||||
|
_btnActivate.Click += async (_, _) => await ActivateAsync();
|
||||||
|
|
||||||
|
_btnExit = new Button
|
||||||
|
{
|
||||||
|
Text = "Beenden",
|
||||||
|
Location = new Point(354, 146),
|
||||||
|
Size = new Size(94, 30),
|
||||||
|
DialogResult = DialogResult.Cancel
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptButton = _btnActivate;
|
||||||
|
CancelButton = _btnExit;
|
||||||
|
Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatInitialStatus(LicenseResult? lastResult)
|
||||||
|
{
|
||||||
|
if (lastResult == null || lastResult.State == LicenseState.NoLicense) return "";
|
||||||
|
return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ActivateAsync()
|
||||||
|
{
|
||||||
|
var key = _txtKey.Text.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
_lblStatus.Text = "Bitte einen Lizenzschlüssel eingeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_btnActivate.Enabled = false;
|
||||||
|
_lblStatus.ForeColor = Color.DimGray;
|
||||||
|
_lblStatus.Text = "Prüfe Lizenz am Server...";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _client.ValidateAsync(key);
|
||||||
|
if (result.IsUsable && _client.VerifyChecksum(result))
|
||||||
|
{
|
||||||
|
Result = result;
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lblStatus.ForeColor = Color.Firebrick;
|
||||||
|
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_lblStatus.ForeColor = Color.Firebrick;
|
||||||
|
_lblStatus.Text = $"Fehler bei der Prüfung: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_btnActivate.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using LicenseLabrador.Client;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startup license gate backed by the LicenseLabrador server.
|
||||||
|
/// Endpoint, product slug and the Ed25519 public key are deliberately compiled in
|
||||||
|
/// (not user configuration): a configurable endpoint/key would let anyone point the
|
||||||
|
/// app at a fake license server.
|
||||||
|
/// </summary>
|
||||||
|
public static class LicenseGuard
|
||||||
|
{
|
||||||
|
private const string ProductSlug = "predictalytics";
|
||||||
|
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
|
||||||
|
private const string BasicAuthUser = "Labrador";
|
||||||
|
private const string BasicAuthPassword = "Labrador02763!";
|
||||||
|
// HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET
|
||||||
|
// macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405,
|
||||||
|
// das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense.
|
||||||
|
// Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung.
|
||||||
|
private static readonly string[] Endpoints = { "https://license.mhdf.de/public/api/v1" };
|
||||||
|
|
||||||
|
/// <summary>Re-check interval while the app is running (12 h).</summary>
|
||||||
|
public const int RevalidationIntervalMs = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
public static LicenseClient CreateClient()
|
||||||
|
{
|
||||||
|
var config = new LicenseConfig
|
||||||
|
{
|
||||||
|
ProductSlug = ProductSlug,
|
||||||
|
PublicKeyBase64 = PublicKeyBase64,
|
||||||
|
Endpoints = Endpoints,
|
||||||
|
HttpBasicAuthUser = BasicAuthUser,
|
||||||
|
HttpBasicAuthPassword = BasicAuthPassword,
|
||||||
|
OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig
|
||||||
|
};
|
||||||
|
return new LicenseClient(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blocks until a usable license is present. Tries the cached key first; otherwise
|
||||||
|
/// (or when the cached key is no longer usable) shows the license dialog.
|
||||||
|
/// Returns null if the user gave up — the app must exit then.
|
||||||
|
/// </summary>
|
||||||
|
public static LicenseClient? EnsureLicensed()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
var result = client.RevalidateAsync().GetAwaiter().GetResult();
|
||||||
|
if (result.IsUsable && client.VerifyChecksum(result))
|
||||||
|
{
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dialog = new LicenseDialog(client, result);
|
||||||
|
if (dialog.ShowDialog() != DialogResult.OK)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the periodic in-app revalidation. Detects revocation/expiry while the app
|
||||||
|
/// keeps running; on a definitively unusable license the app is shut down.
|
||||||
|
/// </summary>
|
||||||
|
public static System.Windows.Forms.Timer StartPeriodicRevalidation(LicenseClient client)
|
||||||
|
{
|
||||||
|
var timer = new System.Windows.Forms.Timer { Interval = RevalidationIntervalMs };
|
||||||
|
timer.Tick += async (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await client.RevalidateAsync();
|
||||||
|
if (result.IsUsable && client.VerifyChecksum(result))
|
||||||
|
{
|
||||||
|
if (result.State == LicenseState.ValidOffline)
|
||||||
|
{
|
||||||
|
Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.", result.GraceUntil);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.Stop();
|
||||||
|
Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.", result.State, result.Message);
|
||||||
|
MessageBox.Show(
|
||||||
|
$"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.",
|
||||||
|
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
|
||||||
|
System.Windows.Forms.Application.Exit();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Transient errors (network etc.) are handled by the SDK's offline grace —
|
||||||
|
// never kill the app from an unexpected exception here.
|
||||||
|
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timer.Start();
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
|
||||||
|
/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises
|
||||||
|
/// an alarm. A Watchdog outage must never impact the app: every call is best effort.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WatchdogHeartbeatService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
private readonly string _baseUrl;
|
||||||
|
private readonly string _apiKey;
|
||||||
|
private readonly string _source;
|
||||||
|
private readonly string _instance;
|
||||||
|
private readonly int _intervalSeconds;
|
||||||
|
private readonly Func<object?>? _metadataProvider;
|
||||||
|
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
private System.Threading.Timer? _timer;
|
||||||
|
private bool _lastSendFailed;
|
||||||
|
|
||||||
|
public WatchdogHeartbeatService(
|
||||||
|
string baseUrl,
|
||||||
|
string apiKey,
|
||||||
|
string source,
|
||||||
|
string instance,
|
||||||
|
int intervalSeconds,
|
||||||
|
Func<object?>? metadataProvider = null)
|
||||||
|
{
|
||||||
|
_baseUrl = baseUrl.TrimEnd('/');
|
||||||
|
_apiKey = apiKey;
|
||||||
|
_source = source;
|
||||||
|
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
|
||||||
|
_intervalSeconds = Math.Max(15, intervalSeconds);
|
||||||
|
_metadataProvider = metadataProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = new System.Threading.Timer(
|
||||||
|
async _ => await SendHeartbeatAsync("ok"),
|
||||||
|
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
|
||||||
|
Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)",
|
||||||
|
_baseUrl, _source, _intervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports a planned shutdown so it is not alarmed as a crash.
|
||||||
|
/// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping"
|
||||||
|
/// value, and that variant fails the insert with HTTP 500 after the state update.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyStopping()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
source = _source,
|
||||||
|
instance = _instance,
|
||||||
|
kind = "stopped_graceful",
|
||||||
|
severity = "info",
|
||||||
|
message = "Predictalytics wird planmäßig beendet."
|
||||||
|
};
|
||||||
|
// Synchronous with a short cap: the form is closing and must not hang.
|
||||||
|
PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendHeartbeatAsync(string status)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
source = _source,
|
||||||
|
instance = _instance,
|
||||||
|
type = "heartbeat",
|
||||||
|
status,
|
||||||
|
message = (string?)null,
|
||||||
|
metrics = new
|
||||||
|
{
|
||||||
|
uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds,
|
||||||
|
app = _metadataProvider?.Invoke()
|
||||||
|
},
|
||||||
|
group = "C# Applications",
|
||||||
|
interval = _intervalSeconds
|
||||||
|
};
|
||||||
|
|
||||||
|
await PostAsync("/api/heartbeat", payload);
|
||||||
|
|
||||||
|
if (_lastSendFailed)
|
||||||
|
{
|
||||||
|
_lastSendFailed = false;
|
||||||
|
Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log the first failure as warning, subsequent ones quietly (no log flood).
|
||||||
|
if (!_lastSendFailed)
|
||||||
|
{
|
||||||
|
_lastSendFailed = true;
|
||||||
|
Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Watchdog heartbeat failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PostAsync(string path, object payload)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
||||||
|
request.Headers.Add("X-Watchdog-Key", _apiKey);
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
_http.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace Predictalytics.WinFormsHost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a thread-safe write action for the RichTextBox terminal.
|
||||||
|
/// </summary>
|
||||||
|
public static class TerminalHelper
|
||||||
|
{
|
||||||
|
public static Action<string, LogEventLevel> CreateWriteAction(RichTextBox rtb, Control owner)
|
||||||
|
{
|
||||||
|
int lineCount = 0;
|
||||||
|
const int maxLines = 500;
|
||||||
|
|
||||||
|
return (message, level) =>
|
||||||
|
{
|
||||||
|
if (owner.IsDisposed || rtb.IsDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
owner.BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
if (rtb.IsDisposed) return;
|
||||||
|
|
||||||
|
lineCount++;
|
||||||
|
if (lineCount > maxLines)
|
||||||
|
{
|
||||||
|
rtb.Clear();
|
||||||
|
lineCount = 0;
|
||||||
|
rtb.AppendText("[Terminal cleared — log continues]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = level switch
|
||||||
|
{
|
||||||
|
LogEventLevel.Error or LogEventLevel.Fatal => System.Drawing.Color.FromArgb(255, 82, 82),
|
||||||
|
LogEventLevel.Warning => System.Drawing.Color.FromArgb(255, 193, 7),
|
||||||
|
LogEventLevel.Debug => System.Drawing.Color.FromArgb(158, 158, 158),
|
||||||
|
_ => System.Drawing.Color.FromArgb(76, 175, 80)
|
||||||
|
};
|
||||||
|
|
||||||
|
rtb.SelectionStart = rtb.TextLength;
|
||||||
|
rtb.SelectionLength = 0;
|
||||||
|
rtb.SelectionColor = color;
|
||||||
|
rtb.AppendText(message);
|
||||||
|
rtb.SelectionColor = rtb.ForeColor;
|
||||||
|
rtb.ScrollToCaret();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* UI thread shutting down */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-7
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
<assemblyIdentity version="1.0.0.0" name="Predictalytics.Shell"/>
|
<assemblyIdentity version="1.0.0.0" name="Predictalytics.app"/>
|
||||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
<security>
|
<security>
|
||||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
@@ -13,10 +13,4 @@
|
|||||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
</application>
|
</application>
|
||||||
</compatibility>
|
</compatibility>
|
||||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
|
||||||
<windowsSettings>
|
|
||||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
|
||||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
|
||||||
</windowsSettings>
|
|
||||||
</application>
|
|
||||||
</assembly>
|
</assembly>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Server=localhost;Database=Predictalytics_dev;User=root;Password="
|
||||||
|
},
|
||||||
|
"WebServer": {
|
||||||
|
"Port": 5000
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"OpenRouter": {
|
||||||
|
"ApiKey": "",
|
||||||
|
"BaseUrl": "https://openrouter.ai/api/v1",
|
||||||
|
"DefaultModel": "google/gemini-flash-1.5",
|
||||||
|
"ManualAnalysisModel": "anthropic/claude-3-opus"
|
||||||
|
},
|
||||||
|
"PlatformSettings": {
|
||||||
|
"Polymarket": {
|
||||||
|
"EnableCrawling": true
|
||||||
|
},
|
||||||
|
"Limitless": {
|
||||||
|
"EnableCrawling": false
|
||||||
|
},
|
||||||
|
"Azuro": {
|
||||||
|
"EnableCrawling": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Egress": {
|
||||||
|
"Channels": []
|
||||||
|
},
|
||||||
|
"ApiSettings": {
|
||||||
|
"CanControl": true,
|
||||||
|
"AuthRequired": false,
|
||||||
|
"AllowedOrigins": [ "http://localhost:5000" ],
|
||||||
|
"ReadOnlyDatabase": false
|
||||||
|
},
|
||||||
|
"RetentionSettings": {
|
||||||
|
"Enabled": true,
|
||||||
|
"RetentionDays": 180,
|
||||||
|
"CompactionDays": 14,
|
||||||
|
"MaxDatabaseSizeGb": 90.0,
|
||||||
|
"MinRetentionDays": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>Predictalytics.Worker</RootNamespace>
|
<RootNamespace>Predictalytics.Worker</RootNamespace>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var trades = await db.Trades
|
var trades = await db.Trades
|
||||||
.Include(t => t.MarketOutcome).ThenInclude(o => o!.Market)
|
.Include(t => t.MarketOutcome).ThenInclude(o => o.Market)
|
||||||
.Include(t => t.DbMarket)
|
.Include(t => t.DbMarket)
|
||||||
.Include(t => t.Context)
|
.Include(t => t.Context)
|
||||||
.Where(t => t.TraderId == id && t.DbMarketId != null)
|
.Where(t => t.TraderId == id && t.DbMarketId != null)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,173 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
a { color: #5b9dff; text-decoration: none; }
|
||||||
|
a:hover { color: #8ab8ff; }
|
||||||
|
::-webkit-scrollbar { width: 8px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
|
||||||
|
pre { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.22), transparent 60%), #0a0d14; color:#EDEFF5;">
|
||||||
|
|
||||||
|
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
|
||||||
|
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">{{ brandName }}<span style="color:#5b9dff;">.</span></div>
|
||||||
|
</div>
|
||||||
|
<nav style="display:flex; align-items:center; gap:32px;">
|
||||||
|
<a href="./Landing.dc.html#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
|
||||||
|
<a href="./Landing.dc.html#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Pricing</a>
|
||||||
|
<a href="./Landing.dc.html#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
|
||||||
|
<div style="font-size:13.5px; font-weight:700; color:#5b9dff;">API Docs</div>
|
||||||
|
</nav>
|
||||||
|
<a href="./Landing.dc.html" style="padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff);">Get API key</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style="display:flex; max-width:1280px; margin:0 auto;">
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside style="width:230px; flex:none; padding:36px 16px; position:sticky; top:68px; align-self:flex-start; height:calc(100vh - 68px); overflow-y:auto;">
|
||||||
|
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:0 12px 8px;">GETTING STARTED</div>
|
||||||
|
<sc-for list="{{ sidebarTop }}" as="s" hint-placeholder-count="3">
|
||||||
|
<div onClick="{{ s.onClick }}" style="cursor:pointer; padding:9px 12px; border-radius:9px; font-size:13.5px; font-weight:600; color:{{ s.color }}; background:{{ s.bg }}; margin-bottom:2px;">{{ s.label }}</div>
|
||||||
|
</sc-for>
|
||||||
|
<div style="font-size:11px; font-weight:700; color:#5B6377; letter-spacing:0.06em; padding:16px 12px 8px;">ENDPOINTS</div>
|
||||||
|
<sc-for list="{{ sidebarEndpoints }}" as="s" hint-placeholder-count="6">
|
||||||
|
<div onClick="{{ s.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:8px; padding:9px 12px; border-radius:9px; font-size:13px; font-weight:600; color:{{ s.color }}; background:{{ s.bg }}; margin-bottom:2px;">
|
||||||
|
<span style="font-size:10px; font-weight:800; font-family:'Roboto Mono'; color:{{ s.methodColor }};">{{ s.method }}</span>
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<main style="flex:1; min-width:0; padding:36px 40px 100px;">
|
||||||
|
<h1 style="margin:0 0 10px; font-size:30px; font-weight:800; letter-spacing:-0.02em;">API Reference</h1>
|
||||||
|
<p style="margin:0 0 32px; color:#8B93A7; font-size:14.5px; max-width:640px; line-height:1.6;">Programmatic access to every trader and market metric in {{ brandName }}. All endpoints return JSON over HTTPS.</p>
|
||||||
|
|
||||||
|
<!-- Auth -->
|
||||||
|
<section style="margin-bottom:40px;">
|
||||||
|
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Authentication</h2>
|
||||||
|
<p style="color:#8B93A7; font-size:13.5px; line-height:1.7; margin:0 0 14px;">Pass your API key as a bearer token on every request. Keys are available from your account dashboard after signing up.</p>
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:18px 20px; font-family:'Roboto Mono'; font-size:13px; color:#C7CCDA; overflow-x:auto;">
|
||||||
|
<pre>curl https://api.predictalytics.io/v1/markets \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Rate limits -->
|
||||||
|
<section style="margin-bottom:40px;">
|
||||||
|
<h2 style="font-size:19px; font-weight:800; margin:0 0 12px;">Rate limits</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:14px;">
|
||||||
|
<sc-for list="{{ rateLimits }}" as="r" hint-placeholder-count="3">
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px;">
|
||||||
|
<div style="font-size:12.5px; font-weight:700; color:#8B93A7; margin-bottom:6px;">{{ r.plan }}</div>
|
||||||
|
<div style="font-size:20px; font-weight:800; font-family:'Roboto Mono';">{{ r.limit }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Endpoints -->
|
||||||
|
<sc-for list="{{ endpoints }}" as="ep" hint-placeholder-count="6">
|
||||||
|
<section id="{{ ep.id }}" style="margin-bottom:40px; scroll-margin-top:80px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
|
||||||
|
<span style="font-size:11px; font-weight:800; font-family:'Roboto Mono'; padding:4px 9px; border-radius:6px; background:{{ ep.methodBg }}; color:{{ ep.methodColor }};">{{ ep.method }}</span>
|
||||||
|
<span style="font-family:'Roboto Mono'; font-size:14.5px; font-weight:700; color:#EDEFF5;">{{ ep.path }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="color:#8B93A7; font-size:13.5px; line-height:1.6; margin:0 0 14px;">{{ ep.desc }}</p>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:14px;">
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:16px 18px;">
|
||||||
|
<div style="font-size:11px; font-weight:700; color:#5B6377; text-transform:uppercase; margin-bottom:10px;">Parameters</div>
|
||||||
|
<sc-for list="{{ ep.params }}" as="p" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:8px; margin-bottom:8px; font-size:12.5px;">
|
||||||
|
<span style="font-family:'Roboto Mono'; font-weight:700; color:#5b9dff;">{{ p.name }}</span>
|
||||||
|
<span style="color:#5B6377; font-size:11.5px;">{{ p.type }}</span>
|
||||||
|
<span style="color:#8B93A7;">{{ p.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); padding:16px 18px; font-family:'Roboto Mono'; font-size:12px; color:#C7CCDA; overflow-x:auto;">
|
||||||
|
<pre>{{ ep.example }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1440}, "brandName": {"editor": "text", "default": "Predictalytics", "tsType": "string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
scrollTo(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const brandName = this.props.brandName ?? "Predictalytics";
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{ id: "get-markets", method: "GET", path: "/v1/markets", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
|
||||||
|
desc: "List all markets with current price, volume, and liquidity.",
|
||||||
|
params: [{ name: "category", type: "string", desc: "Filter by category" }, { name: "status", type: "string", desc: "active | resolved" }],
|
||||||
|
example: `{\n "id": "fed-sep-cut",\n "question": "Fed cuts rates...",\n "yesPrice": 71.4,\n "volume24h": 2840000\n}` },
|
||||||
|
{ id: "get-market", method: "GET", path: "/v1/markets/{id}", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
|
||||||
|
desc: "Get detailed data for a single market including order book and holders.",
|
||||||
|
params: [{ name: "id", type: "string", desc: "Market identifier" }],
|
||||||
|
example: `{\n "orderBook": { "bids": [...], "asks": [...] },\n "holders": [ { "trader": "0xA1...", "size": 420000 } ]\n}` },
|
||||||
|
{ id: "get-traders", method: "GET", path: "/v1/traders", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
|
||||||
|
desc: "Search and list traders with P&L, win rate, and traits.",
|
||||||
|
params: [{ name: "trait", type: "string", desc: "Bot | Arbitrage Trader | Resolution Farming" }, { name: "sort", type: "string", desc: "pnl | volume" }],
|
||||||
|
example: `{\n "handle": "quant_owl",\n "traits": ["Arbitrage Trader"],\n "winRate": 68.4\n}` },
|
||||||
|
{ id: "get-trader", method: "GET", path: "/v1/traders/{id}", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
|
||||||
|
desc: "Get a trader's full position history and connected wallets.",
|
||||||
|
params: [{ name: "id", type: "string", desc: "Trader handle or wallet" }],
|
||||||
|
example: `{\n "positions": [...],\n "connectedWallets": ["0x88Ac...2f01"]\n}` },
|
||||||
|
{ id: "post-alerts", method: "POST", path: "/v1/alerts", methodBg: "rgba(22,82,240,0.16)", methodColor: "#5b9dff",
|
||||||
|
desc: "Create an alert on a trader or market.",
|
||||||
|
params: [{ name: "target", type: "string", desc: "Trader or market id" }, { name: "condition", type: "string", desc: "price_move | new_position" }],
|
||||||
|
example: `{\n "id": "alert_881",\n "target": "arb_meridian",\n "condition": "new_position"\n}` },
|
||||||
|
{ id: "get-agent", method: "GET", path: "/v1/mcp/context", methodBg: "rgba(18,212,138,0.14)", methodColor: "#12D48A",
|
||||||
|
desc: "Enterprise only. MCP-compatible context feed for your own AI agent.",
|
||||||
|
params: [{ name: "scope", type: "string", desc: "markets | traders | both" }],
|
||||||
|
example: `{\n "mcp_version": "1.0",\n "resources": ["markets", "traders"]\n}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebarTop = [
|
||||||
|
{ label: "Authentication", color: "#C7CCDA", bg: "transparent", onClick: () => this.scrollTo("auth") },
|
||||||
|
{ label: "Rate limits", color: "#C7CCDA", bg: "transparent", onClick: () => this.scrollTo("rate-limits") },
|
||||||
|
];
|
||||||
|
const sidebarEndpoints = endpoints.map((ep) => ({
|
||||||
|
label: ep.path, method: ep.method, methodColor: ep.methodColor, color: "#C7CCDA", bg: "transparent",
|
||||||
|
onClick: () => this.scrollTo(ep.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rateLimits = [
|
||||||
|
{ plan: "Free", limit: "20 / day" },
|
||||||
|
{ plan: "Basic", limit: "200 / day" },
|
||||||
|
{ plan: "Enterprise", limit: "2,000 / day" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return { brandName, endpoints, sidebarTop, sidebarEndpoints, rateLimits };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
a { color: #5b9dff; text-decoration: none; }
|
||||||
|
a:hover { color: #8ab8ff; }
|
||||||
|
::-webkit-scrollbar { width: 8px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative; min-height:100vh; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5; overflow-x:hidden;">
|
||||||
|
|
||||||
|
<!-- NAV -->
|
||||||
|
<header style="position:sticky; top:0; z-index:50; display:flex; align-items:center; justify-content:space-between; padding:16px 48px; backdrop-filter:blur(20px); background:rgba(10,13,20,0.6); border-bottom:1px solid rgba(255,255,255,0.07);">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
|
||||||
|
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">{{ brandName }}<span style="color:#5b9dff;">.</span></div>
|
||||||
|
</div>
|
||||||
|
<nav style="display:flex; align-items:center; gap:32px;">
|
||||||
|
<a href="#features" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Features</a>
|
||||||
|
<a href="#pricing" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">Pricing</a>
|
||||||
|
<a href="#faq" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">FAQ</a>
|
||||||
|
<a href="./API Docs.dc.html" style="font-size:13.5px; font-weight:600; color:#C7CCDA;">API Docs</a>
|
||||||
|
</nav>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<div onClick="{{ openLogin }}" style="cursor:pointer; padding:9px 18px; border-radius:10px; font-size:13.5px; font-weight:700; color:#EDEFF5;">Log in</div>
|
||||||
|
<div onClick="{{ openRegister }}" style="cursor:pointer; padding:9px 20px; border-radius:10px; font-size:13.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 4px 20px rgba(22,82,240,0.4);">Sign up</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<section style="padding:100px 48px 80px; max-width:1080px; margin:0 auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:22px;">
|
||||||
|
<div style="padding:6px 14px; border-radius:20px; background:rgba(22,82,240,0.12); border:1px solid rgba(22,82,240,0.3); font-size:12.5px; font-weight:700; color:#5b9dff;">Live on-chain analytics · Polymarket & beyond</div>
|
||||||
|
<h1 style="margin:0; font-size:52px; font-weight:800; letter-spacing:-0.03em; line-height:1.08; max-width:760px;">See every trader and market before the crowd does</h1>
|
||||||
|
<p style="margin:0; font-size:17px; color:#8B93A7; max-width:600px; line-height:1.6;">Deep trader profiling, wallet clustering, and real-time market analytics for professional prediction market traders.</p>
|
||||||
|
<div style="display:flex; gap:14px; margin-top:8px;">
|
||||||
|
<div onClick="{{ openRegister }}" style="cursor:pointer; padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 30px rgba(22,82,240,0.4);">Start free</div>
|
||||||
|
<a href="#features" style="padding:14px 26px; border-radius:12px; font-size:14.5px; font-weight:700; color:#EDEFF5; border:1px solid rgba(255,255,255,0.14); background:rgba(255,255,255,0.04); white-space:nowrap;">See features</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- product preview mock -->
|
||||||
|
<div style="margin-top:40px; width:100%; border-radius:20px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 20px 60px rgba(0,0,0,0.4); padding:20px; display:grid; grid-template-columns:1.4fr 1fr; gap:14px; text-align:left;">
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px;">
|
||||||
|
<div style="font-size:12px; font-weight:700; color:#8B93A7; margin-bottom:10px;">Yes Price History</div>
|
||||||
|
<svg viewBox="0 0 500 140" style="width:100%; height:140px;">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="hfill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="{{ heroArea }}" fill="url(#hfill)" stroke="none"></path>
|
||||||
|
<path d="{{ heroLine }}" fill="none" stroke="#12D48A" stroke-width="2.5"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.07); padding:18px; display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<div style="font-size:12px; font-weight:700; color:#8B93A7;">Top Traders</div>
|
||||||
|
<sc-for list="{{ heroTraders }}" as="t" hint-placeholder-count="3">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<div style="width:24px; height:24px; border-radius:7px; background:{{ t.bg }};"></div>
|
||||||
|
<div style="font-size:12.5px; font-weight:700; flex:1;">{{ t.handle }}</div>
|
||||||
|
<div style="font-size:12px; font-family:'Roboto Mono'; font-weight:700; color:#12D48A;">{{ t.pnl }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section id="features" style="padding:80px 48px; max-width:1200px; margin:0 auto;">
|
||||||
|
<div style="text-align:center; margin-bottom:48px;">
|
||||||
|
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FEATURES</div>
|
||||||
|
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Everything you need to read the market</h2>
|
||||||
|
<p style="margin:0; color:#8B93A7; font-size:15px;">Built for quants who need signal, not noise.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px;">
|
||||||
|
<sc-for list="{{ features }}" as="f" hint-placeholder-count="6">
|
||||||
|
<div style="padding:26px; border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px);">
|
||||||
|
<div style="width:42px; height:42px; border-radius:12px; background:{{ f.iconBg }}; margin-bottom:16px; display:flex; align-items:center; justify-content:center;">
|
||||||
|
<div style="width:16px; height:16px; border-radius:5px; background:{{ f.dotColor }};"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:16px; font-weight:700; margin-bottom:8px;">{{ f.title }}</div>
|
||||||
|
<div style="font-size:13.5px; color:#8B93A7; line-height:1.6;">{{ f.desc }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PRICING -->
|
||||||
|
<section id="pricing" style="padding:80px 48px; max-width:1160px; margin:0 auto;">
|
||||||
|
<div style="text-align:center; margin-bottom:48px;">
|
||||||
|
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">PRICING</div>
|
||||||
|
<h2 style="margin:0 0 12px; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Simple, usage-based plans</h2>
|
||||||
|
<p style="margin:0; color:#8B93A7; font-size:15px;">Upgrade any time as your call volume grows.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px; align-items:stretch;">
|
||||||
|
<sc-for list="{{ plans }}" as="p" hint-placeholder-count="3">
|
||||||
|
<div style="{{ p.cardStyle }}">
|
||||||
|
<sc-if value="{{ p.featured }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:absolute; top:-13px; left:50%; transform:translateX(-50%); padding:5px 14px; border-radius:20px; background:linear-gradient(135deg,#1652F0,#4c8dff); font-size:11px; font-weight:800; letter-spacing:0.04em;">MOST POPULAR</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="font-size:15px; font-weight:700; margin-bottom:6px;">{{ p.name }}</div>
|
||||||
|
<div style="display:flex; align-items:baseline; gap:4px; margin-bottom:18px;">
|
||||||
|
<div style="font-size:36px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">{{ p.price }}</div>
|
||||||
|
<div style="font-size:13px; color:#8B93A7;">{{ p.period }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
|
||||||
|
<sc-for list="{{ p.items }}" as="item" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px; font-size:13.5px; color:#C7CCDA;">
|
||||||
|
<div style="width:6px; height:6px; border-radius:50%; background:#12D48A; flex:none;"></div>
|
||||||
|
<span>{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div onClick="{{ openRegister }}" style="{{ p.btnStyle }}">{{ p.btnLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section id="faq" style="padding:80px 48px 100px; max-width:820px; margin:0 auto;">
|
||||||
|
<div style="text-align:center; margin-bottom:44px;">
|
||||||
|
<div style="font-size:12.5px; font-weight:700; color:#5b9dff; margin-bottom:10px;">FAQ</div>
|
||||||
|
<h2 style="margin:0; font-size:34px; font-weight:800; letter-spacing:-0.02em;">Common questions</h2>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<sc-for list="{{ faqs }}" as="q" hint-placeholder-count="6">
|
||||||
|
<div style="border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); overflow:hidden;">
|
||||||
|
<div onClick="{{ q.onToggle }}" style="cursor:pointer; padding:18px 22px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
|
||||||
|
<div style="font-size:14.5px; font-weight:700;">{{ q.question }}</div>
|
||||||
|
<div style="flex:none; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:16px; color:#5b9dff; transform:{{ q.iconTransform }};">+</div>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ q.open }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:0 22px 18px; font-size:13.5px; color:#8B93A7; line-height:1.7;">{{ q.answer }}</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer style="padding:32px 48px; border-top:1px solid rgba(255,255,255,0.07); display:flex; align-items:center; justify-content:space-between; color:#5B6377; font-size:12.5px;">
|
||||||
|
<div>© 2026 {{ brandName }}. All rights reserved.</div>
|
||||||
|
<div style="display:flex; gap:20px;">
|
||||||
|
<a href="./API Docs.dc.html" style="color:#5B6377;">API Docs</a>
|
||||||
|
<a href="#faq" style="color:#5B6377;">FAQ</a>
|
||||||
|
<a href="#pricing" style="color:#5B6377;">Pricing</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- AUTH MODAL -->
|
||||||
|
<sc-if value="{{ authOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAuth }}" style="position:fixed; inset:0; z-index:100; background:rgba(6,8,13,0.7); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center;">
|
||||||
|
<div onClick="{{ stopProp }}" style="width:400px; max-width:92vw; border-radius:20px; background:rgba(16,20,29,0.9); border:1px solid rgba(255,255,255,0.1); backdrop-filter:blur(30px); box-shadow:0 30px 80px rgba(0,0,0,0.5); padding:32px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
|
||||||
|
<div style="font-size:19px; font-weight:800;">{{ authTitle }}</div>
|
||||||
|
<div onClick="{{ closeAuth }}" style="cursor:pointer; width:28px; height:28px; border-radius:8px; background:rgba(255,255,255,0.06); display:flex; align-items:center; justify-content:center; font-size:14px; color:#8B93A7;">✕</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:14px;">
|
||||||
|
<sc-if value="{{ isRegisterMode }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||||
|
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Full name</label>
|
||||||
|
<input placeholder="Jane Trader" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||||
|
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Email</label>
|
||||||
|
<input placeholder="you@domain.com" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||||
|
<label style="font-size:12.5px; font-weight:600; color:#C7CCDA;">Password</label>
|
||||||
|
<input type="password" placeholder="••••••••" style="padding:11px 14px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13.5px; font-family:'Manrope'; outline:none;" />
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px; padding:13px; border-radius:11px; text-align:center; font-size:14px; font-weight:700; cursor:pointer; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4);">{{ authSubmitLabel }}</div>
|
||||||
|
<div style="text-align:center; font-size:12.5px; color:#8B93A7; margin-top:4px;">
|
||||||
|
{{ authSwitchPrompt }} <span onClick="{{ switchAuthMode }}" style="color:#5b9dff; font-weight:700; cursor:pointer;">{{ authSwitchAction }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview": {"width": 1440}, "brandName": {"editor": "text", "default": "Predictalytics", "tsType": "string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = {
|
||||||
|
authOpen: false,
|
||||||
|
authMode: "login",
|
||||||
|
openFaq: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
openLogin = () => this.setState({ authOpen: true, authMode: "login" });
|
||||||
|
openRegister = () => this.setState({ authOpen: true, authMode: "register" });
|
||||||
|
closeAuth = () => this.setState({ authOpen: false });
|
||||||
|
switchAuthMode = () => this.setState((s) => ({ authMode: s.authMode === "login" ? "register" : "login" }));
|
||||||
|
stopProp = (e) => e.stopPropagation();
|
||||||
|
toggleFaq = (i) => this.setState((s) => ({ openFaq: s.openFaq === i ? -1 : i }));
|
||||||
|
|
||||||
|
buildPath(series, w, h, pad) {
|
||||||
|
const min = Math.min(...series);
|
||||||
|
const max = Math.max(...series);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const innerW = w - pad * 2;
|
||||||
|
const innerH = h - pad * 2;
|
||||||
|
const pts = series.map((v, i) => {
|
||||||
|
const x = pad + (i / (series.length - 1)) * innerW;
|
||||||
|
const y = pad + innerH - ((v - min) / range) * innerH;
|
||||||
|
return [x, y];
|
||||||
|
});
|
||||||
|
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
|
||||||
|
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
|
||||||
|
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
|
||||||
|
return { line, area };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const brandName = this.props.brandName ?? "Predictalytics";
|
||||||
|
const heroSeries = [40, 44, 41, 48, 52, 50, 58, 55, 62, 66, 63, 70, 68, 74, 71.4];
|
||||||
|
const { line, area } = this.buildPath(heroSeries, 500, 140, 8);
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ title: "Trader Tracking", desc: "Follow any wallet's full position history, P&L, and behavior patterns across every market.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
|
||||||
|
{ title: "Wallet Clustering", desc: "Detect connected wallets and coordinated accounts behind a single trading strategy.", iconBg: "rgba(124,92,255,0.14)", dotColor: "#7c5cff" },
|
||||||
|
{ title: "Market Analysis", desc: "Order book depth, liquidity, and holder concentration for every active market.", iconBg: "rgba(18,212,138,0.14)", dotColor: "#12D48A" },
|
||||||
|
{ title: "Custom Alerts", desc: "Get notified the moment a tracked trader opens a position or a market shifts sharply.", iconBg: "rgba(255,157,76,0.14)", dotColor: "#ff9d4c" },
|
||||||
|
{ title: "Trader Traits", desc: "Automatic tagging — arbitrage traders, resolution farmers, bots — at a glance.", iconBg: "rgba(246,70,93,0.14)", dotColor: "#F6465D" },
|
||||||
|
{ title: "API Access", desc: "Pull every metric programmatically into your own models and dashboards.", iconBg: "rgba(22,82,240,0.14)", dotColor: "#5b9dff" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseCard = "position:relative; padding:30px 26px; border-radius:20px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); display:flex; flex-direction:column;";
|
||||||
|
const featuredCard = "position:relative; padding:30px 26px; border-radius:20px; background:rgba(22,82,240,0.08); border:1px solid rgba(22,82,240,0.35); backdrop-filter:blur(24px); display:flex; flex-direction:column; box-shadow:0 20px 50px rgba(22,82,240,0.2);";
|
||||||
|
const btnPrimary = "cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 8px 24px rgba(22,82,240,0.4);";
|
||||||
|
const btnSecondary = "cursor:pointer; padding:12px; border-radius:11px; text-align:center; font-size:13.5px; font-weight:700; margin-top:auto; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12); color:#EDEFF5;";
|
||||||
|
|
||||||
|
const plans = [
|
||||||
|
{ name: "Free", price: "$0", period: "/ month", featured: false, cardStyle: baseCard, btnStyle: btnSecondary, btnLabel: "Get started",
|
||||||
|
items: ["20 API calls / day", "Restricted web access", "Market overview only", "Community support"] },
|
||||||
|
{ name: "Pro", price: "$99", period: "/ month", featured: true, cardStyle: featuredCard, btnStyle: btnPrimary, btnLabel: "Start Pro",
|
||||||
|
items: ["2,000 API calls / day", "Enhanced AI trader analytics", "MCP access for your AI agent", "Priority support"] },
|
||||||
|
{ name: "Enterprise", price: "Contact us", period: "", featured: false, cardStyle: baseCard, btnStyle: btnSecondary, btnLabel: "Contact sales",
|
||||||
|
items: ["10,000+ API calls / day", "Ready to use AI analytics with your individual prompts", "MCP access for your AI agent", "Priority support"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const faqDefs = [
|
||||||
|
{ q: "What data sources power your analytics?", a: "We index on-chain activity directly from Polymarket and related prediction market contracts in real time, plus supplementary off-chain resolution data." },
|
||||||
|
{ q: "How do you identify traders as bots or arbitrageurs?", a: "We apply behavioral heuristics — trade timing, position sizing, cross-market correlation — to automatically tag wallets with traits like Bot, Arbitrage Trader, or Resolution Farming." },
|
||||||
|
{ q: "Can I track a specific wallet or trader?", a: "Yes. Search any wallet address or handle to pull up its full position history, P&L, and connected wallets." },
|
||||||
|
{ q: "What's included in the API?", a: "Market and trader endpoints covering prices, order books, holders, and trader metrics. Call limits scale with your plan, from 20 to 2,000 requests per day." },
|
||||||
|
{ q: "Do you support alerts?", a: "Yes, set alerts on tracked traders or markets and get notified the moment a position opens or a price moves sharply." },
|
||||||
|
{ q: "Can I cancel or change my plan anytime?", a: "Yes, upgrade, downgrade, or cancel at any time from your account settings — changes apply at the next billing cycle." },
|
||||||
|
];
|
||||||
|
const faqs = faqDefs.map((f, i) => ({
|
||||||
|
question: f.q,
|
||||||
|
answer: f.a,
|
||||||
|
open: this.state.openFaq === i,
|
||||||
|
iconTransform: this.state.openFaq === i ? "rotate(45deg)" : "rotate(0deg)",
|
||||||
|
onToggle: () => this.toggleFaq(i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const heroTraders = [
|
||||||
|
{ handle: "quant_owl", pnl: "+$216K", bg: "linear-gradient(135deg,#1652F0,#4c8dff)" },
|
||||||
|
{ handle: "arb_meridian", pnl: "+$454K", bg: "linear-gradient(135deg,#12D48A,#0a8f5f)" },
|
||||||
|
{ handle: "resolvr", pnl: "+$105K", bg: "linear-gradient(135deg,#7c5cff,#4c8dff)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
brandName,
|
||||||
|
heroLine: line,
|
||||||
|
heroArea: area,
|
||||||
|
heroTraders,
|
||||||
|
features,
|
||||||
|
plans,
|
||||||
|
faqs,
|
||||||
|
authOpen: this.state.authOpen,
|
||||||
|
isRegisterMode: this.state.authMode === "register",
|
||||||
|
authTitle: this.state.authMode === "login" ? "Log in" : "Create your account",
|
||||||
|
authSubmitLabel: this.state.authMode === "login" ? "Log in" : "Create account",
|
||||||
|
authSwitchPrompt: this.state.authMode === "login" ? "Don't have an account?" : "Already have an account?",
|
||||||
|
authSwitchAction: this.state.authMode === "login" ? "Sign up" : "Log in",
|
||||||
|
openLogin: this.openLogin,
|
||||||
|
openRegister: this.openRegister,
|
||||||
|
closeAuth: this.closeAuth,
|
||||||
|
switchAuthMode: this.switchAuthMode,
|
||||||
|
stopProp: this.stopProp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #0a0d14; font-family: 'Manrope', system-ui, sans-serif; overflow-x: auto; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
a { color: #5b9dff; text-decoration: none; }
|
||||||
|
a:hover { color: #8ab8ff; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
.dual-range { -webkit-appearance: none; appearance: none; background: transparent; pointer-events: none; margin: 0; position: absolute; top: 0; left: 0; width: 100%; height: 20px; }
|
||||||
|
.dual-range::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 15px; height: 15px; border-radius: 50%; background: #5b9dff; border: 2px solid #0a0d14; cursor: pointer; box-shadow: 0 0 6px rgba(91,157,255,0.7); margin-top: 0; }
|
||||||
|
.dual-range::-moz-range-thumb { pointer-events: auto; width: 15px; height: 15px; border-radius: 50%; background: #5b9dff; border: 2px solid #0a0d14; cursor: pointer; box-shadow: 0 0 6px rgba(91,157,255,0.7); }
|
||||||
|
.dual-range::-webkit-slider-runnable-track { background: transparent; }
|
||||||
|
.dual-range::-moz-range-track { background: transparent; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="position:relative; min-height:100vh; min-width:1440px; width:100%; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), #0a0d14; color:#EDEFF5;">
|
||||||
|
|
||||||
|
<!-- decorative glow layer -->
|
||||||
|
<div style="position:fixed; inset:0; pointer-events:none; background: radial-gradient(600px 400px at 80% 85%, rgba(76,141,255,0.10), transparent 60%); z-index:0;"></div>
|
||||||
|
|
||||||
|
<div style="position:relative; z-index:1; display:flex; min-height:100vh;">
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside style="width:236px; flex:none; padding:24px 16px; display:flex; flex-direction:column; gap:28px; border-right:1px solid rgba(255,255,255,0.07); background:rgba(255,255,255,0.015);">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; padding:4px 8px;">
|
||||||
|
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(135deg,#1652F0,#4c8dff); box-shadow:0 0 20px rgba(22,82,240,0.6);"></div>
|
||||||
|
<div style="font-weight:800; font-size:17px; letter-spacing:-0.02em;">Lucid<span style="color:#5b9dff;">.</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav style="display:flex; flex-direction:column; gap:2px;">
|
||||||
|
<div style="font:600 10px/1 'Manrope'; letter-spacing:0.08em; color:#5B6377; padding:0 12px 8px;">NAVIGATION</div>
|
||||||
|
<sc-for list="{{ navItems }}" as="item" hint-placeholder-count="4">
|
||||||
|
<div onClick="{{ item.onClick }}" style="{{ item.style }}">
|
||||||
|
<div style="{{ item.dotStyle }}"></div>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style="margin-top:auto; display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<div style="padding:14px; border-radius:14px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(20px);">
|
||||||
|
<div style="font-size:11px; color:#8B93A7; margin-bottom:6px;">Data feed</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:7px;">
|
||||||
|
<div style="width:7px; height:7px; border-radius:50%; background:#12D48A; box-shadow:0 0 8px #12D48A;"></div>
|
||||||
|
<div style="font-size:12.5px; font-weight:600;">Live · on-chain</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; padding:8px 10px;">
|
||||||
|
<div style="width:30px; height:30px; border-radius:50%; background:linear-gradient(135deg,#2a2f3d,#1a1e28); border:1px solid rgba(255,255,255,0.1);"></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12.5px; font-weight:700;">Analyst</div>
|
||||||
|
<div style="font-size:11px; color:#5B6377;">Pro workspace</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<main style="flex:1; min-width:0; padding:22px 32px 60px;">
|
||||||
|
|
||||||
|
<!-- TOPBAR -->
|
||||||
|
<div style="display:flex; align-items:center; gap:16px; margin-bottom:22px;">
|
||||||
|
<div style="flex:1; position:relative; max-width:420px;">
|
||||||
|
<input placeholder="Search markets, traders, wallets…" style="width:100%; padding:11px 14px 11px 38px; border-radius:11px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.09); color:#EDEFF5; font-size:13px; font-family:'Manrope'; outline:none;" />
|
||||||
|
<div style="position:absolute; left:13px; top:50%; transform:translateY(-50%); width:14px; height:14px; border-radius:50%; border:2px solid #5B6377;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto; display:flex; align-items:center; gap:10px;">
|
||||||
|
<div style="padding:8px 14px; border-radius:10px; background:rgba(18,212,138,0.10); border:1px solid rgba(18,212,138,0.25); font-size:12px; font-weight:700; color:#12D48A; font-family:'Roboto Mono';">24H VOL {{ headerVolume }}</div>
|
||||||
|
<div style="width:38px; height:38px; border-radius:11px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.09); display:flex; align-items:center; justify-content:center;">
|
||||||
|
<div style="width:8px; height:8px; border-radius:50%; background:#F6465D;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- breadcrumb -->
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-size:12.5px; margin-bottom:18px; font-weight:600;">
|
||||||
|
<sc-for list="{{ breadcrumbItems }}" as="crumb" hint-placeholder-count="1">
|
||||||
|
<span onClick="{{ crumb.onClick }}" style="{{ crumb.style }}">{{ crumb.label }}</span>
|
||||||
|
<sc-if value="{{ crumb.sep }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="color:#3a4152;">›</span>
|
||||||
|
</sc-if>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ isOverview }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<!-- ================= OVERVIEW ================= -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<h1 style="margin:0 0 4px; font-size:26px; font-weight:800; letter-spacing:-0.02em;">Markets Overview</h1>
|
||||||
|
<div style="font-size:13px; color:#8B93A7;">Real-time analytics across all active prediction markets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- stat cards -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px;">
|
||||||
|
<sc-for list="{{ globalStats }}" as="stat" hint-placeholder-count="4">
|
||||||
|
<div style="padding:18px 20px; border-radius:16px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px); box-shadow:0 8px 30px rgba(0,0,0,0.25);">
|
||||||
|
<div style="font-size:12px; color:#8B93A7; font-weight:600; margin-bottom:10px;">{{ stat.label }}</div>
|
||||||
|
<div style="font-size:24px; font-weight:800; font-family:'Roboto Mono'; letter-spacing:-0.02em;">{{ stat.value }}</div>
|
||||||
|
<div style="font-size:12px; font-weight:700; margin-top:6px; font-family:'Roboto Mono'; color:{{ stat.deltaColor }};">{{ stat.delta }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1.7fr 1fr; gap:16px; align-items:start;">
|
||||||
|
<!-- markets table -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
|
||||||
|
<div style="padding:18px 22px 14px; font-size:15px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">All Markets</div>
|
||||||
|
<table style="width:100%; font-size:13px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
|
||||||
|
<td style="padding:10px 22px;">Market</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Yes %</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">24h</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Volume</td>
|
||||||
|
<td style="padding:10px 22px; text-align:right;">Traders</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<sc-for list="{{ marketRows }}" as="m" hint-placeholder-count="6">
|
||||||
|
<tr onClick="{{ m.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
|
||||||
|
<td style="padding:13px 22px;">
|
||||||
|
<div style="font-weight:700; font-size:13.5px; margin-bottom:3px;">{{ m.question }}</div>
|
||||||
|
<div style="font-size:11.5px; color:#5B6377;">{{ m.category }} · {{ m.statusLabel }}</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700;">{{ m.yesPriceLabel }}</td>
|
||||||
|
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ m.changeColor }};">{{ m.changeLabel }}</td>
|
||||||
|
<td style="padding:13px 10px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ m.volumeLabel }}</td>
|
||||||
|
<td style="padding:13px 22px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ m.tradersLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
</sc-for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- leaderboard -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
|
||||||
|
<div style="padding:18px 22px 14px; font-size:15px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Top Traders</div>
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
<sc-for list="{{ leaderboard }}" as="t" hint-placeholder-count="6">
|
||||||
|
<div onClick="{{ t.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:12px; padding:12px 22px; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
|
||||||
|
<div style="width:30px; height:30px; border-radius:9px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:800;">{{ t.initial }}</div>
|
||||||
|
<div style="min-width:0; flex:1;">
|
||||||
|
<div style="font-weight:700; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ t.handle }}</div>
|
||||||
|
<div style="font-size:11px; color:#5B6377;">{{ t.trait }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right; flex:none;">
|
||||||
|
<div style="font-family:'Roboto Mono'; font-weight:700; font-size:13px; color:#12D48A;">{{ t.pnlLabel }}</div>
|
||||||
|
<div style="font-size:10.5px; color:#5B6377;">P&L</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ isMarket }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<!-- ================= MARKET DETAIL ================= -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; gap:20px;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
|
||||||
|
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:rgba(22,82,240,0.14); border:1px solid rgba(22,82,240,0.3); color:#5b9dff;">{{ marketDetail.category }}</div>
|
||||||
|
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:{{ marketDetail.statusBg }}; color:{{ marketDetail.statusColor }};">{{ marketDetail.status }}</div>
|
||||||
|
</div>
|
||||||
|
<h1 style="margin:0 0 6px; font-size:24px; font-weight:800; letter-spacing:-0.02em; max-width:640px;">{{ marketDetail.question }}</h1>
|
||||||
|
<div style="font-size:12.5px; color:#8B93A7;">Resolves {{ marketDetail.resolves }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right; flex:none;">
|
||||||
|
<div style="font-size:34px; font-weight:800; font-family:'Roboto Mono'; color:#12D48A;">{{ marketDetail.yesPriceBig }}</div>
|
||||||
|
<div style="font-size:12px; color:#8B93A7; font-weight:600;">YES probability</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:18px;">
|
||||||
|
<sc-for list="{{ marketStats }}" as="stat" hint-placeholder-count="4">
|
||||||
|
<div style="padding:16px 18px; border-radius:14px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px);">
|
||||||
|
<div style="font-size:11.5px; color:#8B93A7; font-weight:600; margin-bottom:8px;">{{ stat.label }}</div>
|
||||||
|
<div style="font-size:19px; font-weight:800; font-family:'Roboto Mono';">{{ stat.value }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1.6fr 1fr; gap:16px; margin-bottom:16px; align-items:start;">
|
||||||
|
<!-- price chart -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px 22px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:14px;">
|
||||||
|
<div style="font-size:14px; font-weight:700;">Yes Price History</div>
|
||||||
|
<div style="display:flex; gap:6px;">
|
||||||
|
<div style="font-size:11px; padding:5px 10px; border-radius:7px; background:rgba(22,82,240,0.18); color:#5b9dff; font-weight:700;">30D</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 600 220" style="width:100%; height:220px; overflow:visible;">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#12D48A" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#12D48A" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||||
|
</defs>
|
||||||
|
<path d="{{ marketDetail.areaPath }}" fill="url(#fillGrad)" stroke="none"></path>
|
||||||
|
<path d="{{ marketDetail.linePath }}" fill="none" stroke="#12D48A" stroke-width="2.5" filter="url(#glow)"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- order book -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:18px 20px;">
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:12px;">Order Book Depth</div>
|
||||||
|
<div style="font-size:10.5px; color:#5B6377; font-weight:700; text-transform:uppercase; margin-bottom:6px;">Asks</div>
|
||||||
|
<sc-for list="{{ marketDetail.asks }}" as="row" hint-placeholder-count="5">
|
||||||
|
<div style="position:relative; margin-bottom:3px; height:20px;">
|
||||||
|
<div style="position:absolute; right:0; top:0; bottom:0; width:{{ row.barWidth }}; background:rgba(246,70,93,0.16); border-radius:4px;"></div>
|
||||||
|
<div style="position:relative; display:flex; justify-content:space-between; padding:0 8px; font-size:11.5px; font-family:'Roboto Mono'; line-height:20px;">
|
||||||
|
<span style="color:#F6465D; font-weight:700;">{{ row.priceLabel }}</span>
|
||||||
|
<span style="color:#8B93A7;">{{ row.sizeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<div style="height:1px; background:rgba(255,255,255,0.08); margin:8px 0;"></div>
|
||||||
|
<div style="font-size:10.5px; color:#5B6377; font-weight:700; text-transform:uppercase; margin-bottom:6px;">Bids</div>
|
||||||
|
<sc-for list="{{ marketDetail.bids }}" as="row" hint-placeholder-count="5">
|
||||||
|
<div style="position:relative; margin-bottom:3px; height:20px;">
|
||||||
|
<div style="position:absolute; right:0; top:0; bottom:0; width:{{ row.barWidth }}; background:rgba(18,212,138,0.16); border-radius:4px;"></div>
|
||||||
|
<div style="position:relative; display:flex; justify-content:space-between; padding:0 8px; font-size:11.5px; font-family:'Roboto Mono'; line-height:20px;">
|
||||||
|
<span style="color:#12D48A; font-weight:700;">{{ row.priceLabel }}</span>
|
||||||
|
<span style="color:#8B93A7;">{{ row.sizeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||||
|
<!-- biggest holders -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
|
||||||
|
<div style="padding:16px 20px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Biggest Holders</div>
|
||||||
|
<table style="width:100%; font-size:12.5px;">
|
||||||
|
<tbody>
|
||||||
|
<sc-for list="{{ marketDetail.holders }}" as="h" hint-placeholder-count="5">
|
||||||
|
<tr onClick="{{ h.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
|
||||||
|
<td style="padding:11px 20px; font-family:'Roboto Mono'; font-weight:700;">{{ h.trader }}</td>
|
||||||
|
<td style="padding:11px 6px; text-align:center;"><span style="padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:{{ h.sideBg }}; color:{{ h.sideColor }};">{{ h.side }}</span></td>
|
||||||
|
<td style="padding:11px 20px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ h.valueLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
</sc-for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top traders in market -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
|
||||||
|
<div style="padding:16px 20px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Top Traders Here</div>
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
<sc-for list="{{ marketDetail.topTraders }}" as="t" hint-placeholder-count="4">
|
||||||
|
<div onClick="{{ t.onClick }}" style="cursor:pointer; display:flex; align-items:center; gap:12px; padding:12px 20px; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
|
||||||
|
<div style="width:28px; height:28px; border-radius:8px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:800;">{{ t.initial }}</div>
|
||||||
|
<div style="font-weight:700; font-size:13px; flex:1;">{{ t.handle }}</div>
|
||||||
|
<div style="font-size:11px; color:#8B93A7;">{{ t.trait }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ isTraderSearch }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<!-- ================= TRADER SEARCH ================= -->
|
||||||
|
<div>
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<h1 style="margin:0 0 4px; font-size:26px; font-weight:800; letter-spacing:-0.02em;">Trader Search</h1>
|
||||||
|
<div style="font-size:13px; color:#8B93A7;">Filter by traits and metric ranges to find the traders you care about</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:300px 1fr; gap:16px; align-items:start;">
|
||||||
|
<!-- filter panel -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px; display:flex; flex-direction:column; gap:22px; position:sticky; top:22px;">
|
||||||
|
<div>
|
||||||
|
<input value="{{ searchText }}" onChange="{{ onSearchTextChange }}" placeholder="Handle or wallet…" style="width:100%; padding:10px 13px; border-radius:10px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:13px; font-family:'Manrope'; outline:none;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; font-weight:700; color:#C7CCDA; margin-bottom:10px;">Traits</div>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:7px;">
|
||||||
|
<sc-for list="{{ traitFilterChips }}" as="chip" hint-placeholder-count="5">
|
||||||
|
<div onClick="{{ chip.onClick }}" style="{{ chip.style }}">{{ chip.label }}</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
|
||||||
|
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Win Rate</span>
|
||||||
|
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ winRateSlider.minLabel }} – {{ winRateSlider.maxLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative; height:20px; margin-bottom:10px;">
|
||||||
|
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
|
||||||
|
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ winRateSlider.fillLeft }}; right:{{ winRateSlider.fillRight }};"></div>
|
||||||
|
<input class="dual-range" type="range" min="{{ winRateSlider.lo }}" max="{{ winRateSlider.hi }}" value="{{ winRateSlider.minVal }}" onInput="{{ winRateSlider.onMinInput }}" />
|
||||||
|
<input class="dual-range" type="range" min="{{ winRateSlider.lo }}" max="{{ winRateSlider.hi }}" value="{{ winRateSlider.maxVal }}" onInput="{{ winRateSlider.onMaxInput }}" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<input value="{{ winRateSlider.minVal }}" onBlur="{{ winRateSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
|
||||||
|
<span style="color:#5B6377; font-size:11px;">–</span>
|
||||||
|
<input value="{{ winRateSlider.maxVal }}" onBlur="{{ winRateSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
|
||||||
|
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Volume</span>
|
||||||
|
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ volumeSlider.minLabel }} – {{ volumeSlider.maxLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative; height:20px; margin-bottom:10px;">
|
||||||
|
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
|
||||||
|
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ volumeSlider.fillLeft }}; right:{{ volumeSlider.fillRight }};"></div>
|
||||||
|
<input class="dual-range" type="range" min="{{ volumeSlider.lo }}" max="{{ volumeSlider.hi }}" step="500000" value="{{ volumeSlider.minVal }}" onInput="{{ volumeSlider.onMinInput }}" />
|
||||||
|
<input class="dual-range" type="range" min="{{ volumeSlider.lo }}" max="{{ volumeSlider.hi }}" step="500000" value="{{ volumeSlider.maxVal }}" onInput="{{ volumeSlider.onMaxInput }}" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<input value="{{ volumeSlider.minVal }}" onBlur="{{ volumeSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
|
||||||
|
<span style="color:#5B6377; font-size:11px;">–</span>
|
||||||
|
<input value="{{ volumeSlider.maxVal }}" onBlur="{{ volumeSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:10px;">
|
||||||
|
<span style="font-size:12px; font-weight:700; color:#C7CCDA;">Total P&L</span>
|
||||||
|
<span style="font-size:11px; font-family:'Roboto Mono'; color:#8B93A7;">{{ pnlSlider.minLabel }} – {{ pnlSlider.maxLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative; height:20px; margin-bottom:10px;">
|
||||||
|
<div style="position:absolute; top:8px; left:0; right:0; height:4px; border-radius:2px; background:rgba(255,255,255,0.1);"></div>
|
||||||
|
<div style="position:absolute; top:8px; height:4px; border-radius:2px; background:#5b9dff; left:{{ pnlSlider.fillLeft }}; right:{{ pnlSlider.fillRight }};"></div>
|
||||||
|
<input class="dual-range" type="range" min="{{ pnlSlider.lo }}" max="{{ pnlSlider.hi }}" step="10000" value="{{ pnlSlider.minVal }}" onInput="{{ pnlSlider.onMinInput }}" />
|
||||||
|
<input class="dual-range" type="range" min="{{ pnlSlider.lo }}" max="{{ pnlSlider.hi }}" step="10000" value="{{ pnlSlider.maxVal }}" onInput="{{ pnlSlider.onMaxInput }}" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<input value="{{ pnlSlider.minVal }}" onBlur="{{ pnlSlider.onMinTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
|
||||||
|
<span style="color:#5B6377; font-size:11px;">–</span>
|
||||||
|
<input value="{{ pnlSlider.maxVal }}" onBlur="{{ pnlSlider.onMaxTextBlur }}" style="width:0; flex:1; min-width:0; padding:6px 8px; border-radius:8px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:#EDEFF5; font-size:12px; font-family:'Roboto Mono'; outline:none; text-align:center;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div onClick="{{ onResetFilters }}" style="cursor:pointer; text-align:center; padding:10px; border-radius:10px; font-size:12.5px; font-weight:700; color:#8B93A7; border:1px solid rgba(255,255,255,0.1);">Reset filters</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- results -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
|
||||||
|
<div style="padding:16px 22px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06); display:flex; justify-content:space-between;">
|
||||||
|
<span>Results</span><span style="color:#5B6377; font-weight:600;">{{ searchResultsCount }} traders</span>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%; font-size:13px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
|
||||||
|
<td style="padding:10px 22px;">Trader</td>
|
||||||
|
<td style="padding:10px 10px;">Traits</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Win Rate</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Volume</td>
|
||||||
|
<td style="padding:10px 22px; text-align:right;">P&L</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<sc-for list="{{ searchResults }}" as="t" hint-placeholder-count="6">
|
||||||
|
<tr onClick="{{ t.onClick }}" style="cursor:pointer; border-top:1px solid rgba(255,255,255,0.05);" style-hover="background:rgba(255,255,255,0.03);">
|
||||||
|
<td style="padding:12px 22px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<div style="width:26px; height:26px; border-radius:8px; flex:none; background:{{ t.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:800;">{{ t.initial }}</div>
|
||||||
|
<span style="font-weight:700;">{{ t.handle }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 10px;">
|
||||||
|
<div style="display:flex; gap:5px; flex-wrap:wrap;">
|
||||||
|
<sc-for list="{{ t.traitChips }}" as="chip" hint-placeholder-count="2">
|
||||||
|
<div style="font-size:10.5px; font-weight:700; padding:3px 8px; border-radius:6px; background:{{ chip.bg }}; color:{{ chip.color }};">{{ chip.label }}</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; font-weight:700;">{{ t.winRateLabel }}</td>
|
||||||
|
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#C7CCDA;">{{ t.volumeLabel }}</td>
|
||||||
|
<td style="padding:12px 22px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ t.pnlColor }};">{{ t.pnlLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
</sc-for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ isTrader }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<!-- ================= TRADER DETAIL ================= -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; gap:20px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:16px;">
|
||||||
|
<div style="width:56px; height:56px; border-radius:16px; background:{{ traderDetail.avatarBg }}; display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:800; box-shadow:0 0 24px rgba(22,82,240,0.3);">{{ traderDetail.initial }}</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
|
||||||
|
<h1 style="margin:0; font-size:22px; font-weight:800; letter-spacing:-0.02em;">{{ traderDetail.handle }}</h1>
|
||||||
|
<div style="font-size:12px; font-family:'Roboto Mono'; color:#5B6377;">{{ traderDetail.wallet }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:6px;">
|
||||||
|
<sc-for list="{{ traderDetail.traitChips }}" as="chip" hint-placeholder-count="2">
|
||||||
|
<div style="font-size:11px; font-weight:700; padding:4px 10px; border-radius:7px; background:{{ chip.bg }}; color:{{ chip.color }}; border:1px solid {{ chip.border }};">{{ chip.label }}</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div style="font-size:11.5px; color:#8B93A7; margin-bottom:2px;">Member since {{ traderDetail.joined }}</div>
|
||||||
|
<div style="font-size:11.5px; color:#8B93A7;">{{ traderDetail.activePositionsLabel }} active positions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:16px; margin-bottom:18px;">
|
||||||
|
<sc-for list="{{ traderStats }}" as="stat" hint-placeholder-count="5">
|
||||||
|
<div style="padding:16px 18px; border-radius:14px; background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09); backdrop-filter:blur(24px);">
|
||||||
|
<div style="font-size:11px; color:#8B93A7; font-weight:600; margin-bottom:8px;">{{ stat.label }}</div>
|
||||||
|
<div style="font-size:18px; font-weight:800; font-family:'Roboto Mono'; color:{{ stat.color }};">{{ stat.value }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1.6fr 1fr; gap:16px; margin-bottom:16px; align-items:start;">
|
||||||
|
<!-- pnl chart -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:20px 22px;">
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:14px;">P&L Over Time</div>
|
||||||
|
<svg viewBox="0 0 600 200" style="width:100%; height:200px; overflow:visible;">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pnlFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#5b9dff" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#5b9dff" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow2"><feGaussianBlur stdDeviation="3.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||||
|
</defs>
|
||||||
|
<path d="{{ traderDetail.areaPath }}" fill="url(#pnlFill)" stroke="none"></path>
|
||||||
|
<path d="{{ traderDetail.linePath }}" fill="none" stroke="#5b9dff" stroke-width="2.5" filter="url(#glow2)"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- network -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); padding:18px 20px;">
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:10px;">Connected Wallets</div>
|
||||||
|
<sc-if value="{{ traderDetail.hasConnections }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<sc-for list="{{ traderDetail.connections }}" as="w" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; padding:10px 0; border-top:1px solid rgba(255,255,255,0.05);">
|
||||||
|
<div style="width:8px; height:8px; border-radius:50%; background:#F6465D; box-shadow:0 0 6px #F6465D;"></div>
|
||||||
|
<div style="font-family:'Roboto Mono'; font-size:12.5px; color:#C7CCDA;">{{ w }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ traderDetail.noConnections }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:12.5px; color:#5B6377; padding:10px 0;">No linked wallets detected.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- positions table -->
|
||||||
|
<div style="border-radius:18px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(24px); overflow:hidden;">
|
||||||
|
<div style="padding:16px 22px 12px; font-size:14px; font-weight:700; border-bottom:1px solid rgba(255,255,255,0.06);">Positions</div>
|
||||||
|
<table style="width:100%; font-size:12.5px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="color:#5B6377; font-size:11px; text-transform:uppercase; letter-spacing:0.04em;">
|
||||||
|
<td style="padding:10px 22px;">Market</td>
|
||||||
|
<td style="padding:10px 10px; text-align:center;">Side</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Size</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Entry</td>
|
||||||
|
<td style="padding:10px 10px; text-align:right;">Current</td>
|
||||||
|
<td style="padding:10px 22px; text-align:right;">P&L</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<sc-for list="{{ traderDetail.positions }}" as="p" hint-placeholder-count="4">
|
||||||
|
<tr style="border-top:1px solid rgba(255,255,255,0.05);">
|
||||||
|
<td style="padding:12px 22px; font-weight:600;">{{ p.market }}</td>
|
||||||
|
<td style="padding:12px 10px; text-align:center;"><span style="padding:3px 9px; border-radius:6px; font-size:11px; font-weight:700; background:{{ p.sideBg }}; color:{{ p.sideColor }};">{{ p.side }}</span></td>
|
||||||
|
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono';">{{ p.sizeLabel }}</td>
|
||||||
|
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ p.entryLabel }}</td>
|
||||||
|
<td style="padding:12px 10px; text-align:right; font-family:'Roboto Mono'; color:#8B93A7;">{{ p.currentLabel }}</td>
|
||||||
|
<td style="padding:12px 22px; text-align:right; font-family:'Roboto Mono'; font-weight:700; color:{{ p.pnlColor }};">{{ p.pnlLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
</sc-for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = {
|
||||||
|
view: "overview",
|
||||||
|
selectedMarketId: null,
|
||||||
|
selectedTraderId: null,
|
||||||
|
markets: null,
|
||||||
|
traders: null,
|
||||||
|
searchText: "",
|
||||||
|
filterTraits: [],
|
||||||
|
winRateMin: 0,
|
||||||
|
winRateMax: 100,
|
||||||
|
volumeMin: 0,
|
||||||
|
volumeMax: 25000000,
|
||||||
|
pnlMin: -50000,
|
||||||
|
pnlMax: 500000,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
import("./market-data.js").then((m) => {
|
||||||
|
this.setState({ markets: m.MARKETS, traders: m.TRADERS });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goOverview = () => this.setState({ view: "overview" });
|
||||||
|
goMarket = (id) => this.setState({ view: "market", selectedMarketId: id });
|
||||||
|
goTrader = (id) => this.setState({ view: "trader", selectedTraderId: id });
|
||||||
|
goTraderSearch = () => this.setState({ view: "traderSearch" });
|
||||||
|
|
||||||
|
setSearchText = (e) => this.setState({ searchText: e.target.value });
|
||||||
|
toggleTrait = (trait) => this.setState((s) => ({
|
||||||
|
filterTraits: s.filterTraits.includes(trait) ? s.filterTraits.filter((t) => t !== trait) : [...s.filterTraits, trait],
|
||||||
|
}));
|
||||||
|
setRange = (key) => (e) => this.setState({ [key]: Number(e.target.value) });
|
||||||
|
resetFilters = () => this.setState({
|
||||||
|
searchText: "", filterTraits: [], winRateMin: 0, winRateMax: 100,
|
||||||
|
volumeMin: 0, volumeMax: 25000000, pnlMin: -50000, pnlMax: 500000,
|
||||||
|
});
|
||||||
|
|
||||||
|
parseClamp(raw, lo, hi) {
|
||||||
|
const n = Number(String(raw).replace(/[^0-9.\-]/g, ""));
|
||||||
|
if (Number.isNaN(n)) return null;
|
||||||
|
return Math.max(lo, Math.min(hi, n));
|
||||||
|
}
|
||||||
|
onMinTextBlur = (key, lo, hi) => (e) => {
|
||||||
|
const n = this.parseClamp(e.target.value, lo, hi);
|
||||||
|
this.setState({ [key]: n === null ? lo : n });
|
||||||
|
};
|
||||||
|
onMaxTextBlur = (key, lo, hi) => (e) => {
|
||||||
|
const n = this.parseClamp(e.target.value, lo, hi);
|
||||||
|
this.setState({ [key]: n === null ? hi : n });
|
||||||
|
};
|
||||||
|
|
||||||
|
fmtMoney(n) {
|
||||||
|
if (n >= 1000000) return "$" + (n / 1000000).toFixed(2) + "M";
|
||||||
|
if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "K";
|
||||||
|
return "$" + Math.round(n);
|
||||||
|
}
|
||||||
|
fmtNum(n) {
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
||||||
|
return String(Math.round(n));
|
||||||
|
}
|
||||||
|
fmtSigned(n, suffix) {
|
||||||
|
const s = n >= 0 ? "+" : "";
|
||||||
|
return s + n.toFixed(1) + (suffix || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
traitStyle(trait) {
|
||||||
|
const map = {
|
||||||
|
"Arbitrage Bot": { bg: "rgba(22,82,240,0.14)", color: "#5b9dff", border: "rgba(22,82,240,0.3)" },
|
||||||
|
"Resolution Farming": { bg: "rgba(246,70,93,0.12)", color: "#F6465D", border: "rgba(246,70,93,0.28)" },
|
||||||
|
"Trading Bot": { bg: "rgba(255,255,255,0.08)", color: "#C7CCDA", border: "rgba(255,255,255,0.15)" },
|
||||||
|
"Human": { bg: "rgba(124,92,255,0.12)", color: "#a78bff", border: "rgba(124,92,255,0.28)" },
|
||||||
|
"High Volume": { bg: "rgba(18,212,138,0.12)", color: "#12D48A", border: "rgba(18,212,138,0.28)" },
|
||||||
|
};
|
||||||
|
return map[trait] || { bg: "rgba(255,255,255,0.08)", color: "#C7CCDA", border: "rgba(255,255,255,0.15)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_TRAITS = ["Human", "Trading Bot", "Arbitrage Bot", "Resolution Farming", "High Volume"];
|
||||||
|
|
||||||
|
avatarBg(seed) {
|
||||||
|
const palettes = [
|
||||||
|
"linear-gradient(135deg,#1652F0,#4c8dff)",
|
||||||
|
"linear-gradient(135deg,#12D48A,#0a8f5f)",
|
||||||
|
"linear-gradient(135deg,#F6465D,#a8283a)",
|
||||||
|
"linear-gradient(135deg,#7c5cff,#4c8dff)",
|
||||||
|
"linear-gradient(135deg,#ff9d4c,#F6465D)",
|
||||||
|
];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||||
|
return palettes[h % palettes.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDualSlider(minKey, maxKey, lo, hi, fmt) {
|
||||||
|
const minVal = this.state[minKey];
|
||||||
|
const maxVal = this.state[maxKey];
|
||||||
|
const pctMin = ((minVal - lo) / (hi - lo)) * 100;
|
||||||
|
const pctMax = ((maxVal - lo) / (hi - lo)) * 100;
|
||||||
|
return {
|
||||||
|
lo, hi, minVal, maxVal,
|
||||||
|
minLabel: fmt(minVal), maxLabel: fmt(maxVal),
|
||||||
|
fillLeft: pctMin.toFixed(1) + "%",
|
||||||
|
fillRight: (100 - pctMax).toFixed(1) + "%",
|
||||||
|
onMinInput: (e) => this.setState({ [minKey]: Math.min(Number(e.target.value), this.state[maxKey]) }),
|
||||||
|
onMaxInput: (e) => this.setState({ [maxKey]: Math.max(Number(e.target.value), this.state[minKey]) }),
|
||||||
|
onMinTextBlur: this.onMinTextBlur(minKey, lo, hi),
|
||||||
|
onMaxTextBlur: this.onMaxTextBlur(maxKey, lo, hi),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPath(series, w, h, pad) {
|
||||||
|
if (!series || series.length < 2) return { line: "", area: "" };
|
||||||
|
const min = Math.min(...series);
|
||||||
|
const max = Math.max(...series);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const innerW = w - pad * 2;
|
||||||
|
const innerH = h - pad * 2;
|
||||||
|
const pts = series.map((v, i) => {
|
||||||
|
const x = pad + (i / (series.length - 1)) * innerW;
|
||||||
|
const y = pad + innerH - ((v - min) / range) * innerH;
|
||||||
|
return [x, y];
|
||||||
|
});
|
||||||
|
let line = "M" + pts[0][0].toFixed(1) + "," + pts[0][1].toFixed(1);
|
||||||
|
for (let i = 1; i < pts.length; i++) line += " L" + pts[i][0].toFixed(1) + "," + pts[i][1].toFixed(1);
|
||||||
|
const area = line + ` L${pts[pts.length - 1][0].toFixed(1)},${h - pad} L${pts[0][0].toFixed(1)},${h - pad} Z`;
|
||||||
|
return { line, area };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const markets = this.state.markets || [];
|
||||||
|
const traders = this.state.traders || [];
|
||||||
|
const view = this.state.view;
|
||||||
|
|
||||||
|
const navDefs = [
|
||||||
|
{ key: "overview", label: "Markets" },
|
||||||
|
{ key: "traders", label: "Traders" },
|
||||||
|
{ key: "watchlist", label: "Watchlist" },
|
||||||
|
{ key: "settings", label: "Settings" },
|
||||||
|
];
|
||||||
|
const navItems = navDefs.map((n) => {
|
||||||
|
const active = (n.key === "overview" && view === "overview") ||
|
||||||
|
(n.key === "traders" && (view === "trader" || view === "traderSearch")) ||
|
||||||
|
(n.key === "watchlist" && false) ||
|
||||||
|
(n.key === "settings" && false);
|
||||||
|
return {
|
||||||
|
label: n.label,
|
||||||
|
onClick: n.key === "overview" ? this.goOverview : (n.key === "traders" ? this.goTraderSearch : this.goOverview),
|
||||||
|
style: `display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:10px; font-size:13.5px; font-weight:600; cursor:pointer; color:${active ? "#EDEFF5" : "#8B93A7"}; background:${active ? "rgba(22,82,240,0.14)" : "transparent"};`,
|
||||||
|
dotStyle: `width:6px; height:6px; border-radius:50%; background:${active ? "#5b9dff" : "#3a4152"};`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- global stats
|
||||||
|
const totalVol24 = markets.reduce((s, m) => s + m.volume24h, 0);
|
||||||
|
const openMarkets = markets.filter((m) => m.status === "Active").length;
|
||||||
|
const totalTraders = new Set(traders.map((t) => t.id)).size + markets.reduce((s, m) => s + m.traders, 0) * 0; // display sum below
|
||||||
|
const totalActiveTraders = markets.reduce((s, m) => s + m.traders, 0);
|
||||||
|
const avgLiquidity = markets.length ? markets.reduce((s, m) => s + m.liquidity, 0) / markets.length : 0;
|
||||||
|
|
||||||
|
const globalStats = [
|
||||||
|
{ label: "24H Volume", value: this.fmtMoney(totalVol24), delta: this.fmtSigned(3.4, "%"), deltaColor: "#12D48A" },
|
||||||
|
{ label: "Open Markets", value: String(openMarkets), delta: "+2 today", deltaColor: "#12D48A" },
|
||||||
|
{ label: "Active Traders", value: this.fmtNum(totalActiveTraders), delta: this.fmtSigned(1.8, "%"), deltaColor: "#12D48A" },
|
||||||
|
{ label: "Avg. Liquidity", value: this.fmtMoney(avgLiquidity), delta: this.fmtSigned(-0.6, "%"), deltaColor: "#F6465D" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const marketRows = markets.map((m) => ({
|
||||||
|
question: m.question,
|
||||||
|
category: m.category,
|
||||||
|
statusLabel: m.status,
|
||||||
|
yesPriceLabel: m.yesPrice.toFixed(1) + "%",
|
||||||
|
changeLabel: this.fmtSigned(m.change24h, "%"),
|
||||||
|
changeColor: m.change24h >= 0 ? "#12D48A" : "#F6465D",
|
||||||
|
volumeLabel: this.fmtMoney(m.volume24h),
|
||||||
|
tradersLabel: this.fmtNum(m.traders),
|
||||||
|
onClick: () => this.goMarket(m.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const leaderboard = [...traders].sort((a, b) => (b.pnlRealized + b.pnlUnrealized) - (a.pnlRealized + a.pnlUnrealized)).slice(0, 6).map((t) => ({
|
||||||
|
handle: t.handle,
|
||||||
|
trait: t.traits[0],
|
||||||
|
initial: t.handle[0].toUpperCase(),
|
||||||
|
avatarBg: this.avatarBg(t.id),
|
||||||
|
pnlLabel: this.fmtMoney(t.pnlRealized + t.pnlUnrealized),
|
||||||
|
onClick: () => this.goTrader(t.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const headerVolume = this.fmtMoney(totalVol24);
|
||||||
|
const crumbDefs = view === "overview" ? [["Markets Overview", null]] :
|
||||||
|
view === "market" ? [["Markets Overview", this.goOverview], ["Market Detail", null]] :
|
||||||
|
view === "traderSearch" ? [["Trader Search", null]] :
|
||||||
|
[["Trader Search", this.goTraderSearch], ["Trader Detail", null]];
|
||||||
|
const breadcrumbItems = crumbDefs.map(([label, onClick], i) => ({
|
||||||
|
label, onClick,
|
||||||
|
style: onClick ? "cursor:pointer; color:#8B93A7;" : "color:#EDEFF5; cursor:default;",
|
||||||
|
sep: i < crumbDefs.length - 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- trader search
|
||||||
|
let searchResults = [];
|
||||||
|
let traitFilterChips = [];
|
||||||
|
if (view === "traderSearch") {
|
||||||
|
const q = this.state.searchText.trim().toLowerCase();
|
||||||
|
traitFilterChips = this.ALL_TRAITS.map((tr) => {
|
||||||
|
const active = this.state.filterTraits.includes(tr);
|
||||||
|
const s = this.traitStyle(tr);
|
||||||
|
return {
|
||||||
|
label: tr,
|
||||||
|
onClick: () => this.toggleTrait(tr),
|
||||||
|
style: `cursor:pointer; font-size:12px; font-weight:700; padding:7px 13px; border-radius:9px; background:${active ? s.bg : "rgba(255,255,255,0.04)"}; color:${active ? s.color : "#8B93A7"}; border:1px solid ${active ? s.border : "rgba(255,255,255,0.09)"};`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
searchResults = traders.filter((t) => {
|
||||||
|
if (q && !t.handle.toLowerCase().includes(q) && !t.wallet.toLowerCase().includes(q)) return false;
|
||||||
|
if (this.state.filterTraits.length && !this.state.filterTraits.every((tr) => t.traits.includes(tr))) return false;
|
||||||
|
if (t.winRate < this.state.winRateMin || t.winRate > this.state.winRateMax) return false;
|
||||||
|
if (t.volume < this.state.volumeMin || t.volume > this.state.volumeMax) return false;
|
||||||
|
const totalPnl = t.pnlRealized + t.pnlUnrealized;
|
||||||
|
if (totalPnl < this.state.pnlMin || totalPnl > this.state.pnlMax) return false;
|
||||||
|
return true;
|
||||||
|
}).map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
handle: t.handle,
|
||||||
|
initial: t.handle[0].toUpperCase(),
|
||||||
|
avatarBg: this.avatarBg(t.id),
|
||||||
|
traitChips: t.traits.map((tr) => {
|
||||||
|
const s = this.traitStyle(tr);
|
||||||
|
return { label: tr, bg: s.bg, color: s.color, border: s.border };
|
||||||
|
}),
|
||||||
|
winRateLabel: t.winRate.toFixed(1) + "%",
|
||||||
|
volumeLabel: this.fmtMoney(t.volume),
|
||||||
|
pnlLabel: ((t.pnlRealized + t.pnlUnrealized) >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlRealized + t.pnlUnrealized)),
|
||||||
|
pnlColor: (t.pnlRealized + t.pnlUnrealized) >= 0 ? "#12D48A" : "#F6465D",
|
||||||
|
onClick: () => this.goTrader(t.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- market detail
|
||||||
|
let marketDetail = null;
|
||||||
|
let marketStats = [];
|
||||||
|
if (view === "market") {
|
||||||
|
const m = markets.find((x) => x.id === this.state.selectedMarketId) || markets[0];
|
||||||
|
if (m) {
|
||||||
|
const { line, area } = this.buildPath(m.yesSeries, 600, 220, 14);
|
||||||
|
const maxBookSize = Math.max(1, ...m.orderBook.bids.map((b) => b[1]), ...m.orderBook.asks.map((a) => a[1]));
|
||||||
|
marketDetail = {
|
||||||
|
question: m.question,
|
||||||
|
category: m.category,
|
||||||
|
status: m.status,
|
||||||
|
statusBg: m.status === "Active" ? "rgba(18,212,138,0.12)" : "rgba(255,255,255,0.08)",
|
||||||
|
statusColor: m.status === "Active" ? "#12D48A" : "#8B93A7",
|
||||||
|
resolves: m.resolves,
|
||||||
|
yesPriceBig: m.yesPrice.toFixed(1) + "%",
|
||||||
|
linePath: line,
|
||||||
|
areaPath: area,
|
||||||
|
asks: [...m.orderBook.asks].reverse().map(([p, s]) => ({ priceLabel: p.toFixed(1) + "%", sizeLabel: this.fmtMoney(s), barWidth: Math.round((s / maxBookSize) * 100) + "%" })),
|
||||||
|
bids: m.orderBook.bids.map(([p, s]) => ({ priceLabel: p.toFixed(1) + "%", sizeLabel: this.fmtMoney(s), barWidth: Math.round((s / maxBookSize) * 100) + "%" })),
|
||||||
|
holders: m.holders.map((h) => ({
|
||||||
|
trader: h.trader,
|
||||||
|
side: h.side,
|
||||||
|
sideBg: h.side === "Yes" ? "rgba(18,212,138,0.14)" : "rgba(246,70,93,0.14)",
|
||||||
|
sideColor: h.side === "Yes" ? "#12D48A" : "#F6465D",
|
||||||
|
valueLabel: this.fmtMoney(h.value),
|
||||||
|
onClick: () => {
|
||||||
|
const t = traders.find((tr) => tr.handle === h.trader || tr.wallet === h.trader);
|
||||||
|
if (t) this.goTrader(t.id);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
topTraders: m.topTraders.map((handle) => {
|
||||||
|
const t = traders.find((tr) => tr.handle === handle);
|
||||||
|
return t ? {
|
||||||
|
handle: t.handle,
|
||||||
|
trait: t.traits[0],
|
||||||
|
initial: t.handle[0].toUpperCase(),
|
||||||
|
avatarBg: this.avatarBg(t.id),
|
||||||
|
onClick: () => this.goTrader(t.id),
|
||||||
|
} : { handle, trait: "", initial: handle[0].toUpperCase(), avatarBg: "#333", onClick: () => {} };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
marketStats = [
|
||||||
|
{ label: "24H Volume", value: this.fmtMoney(m.volume24h) },
|
||||||
|
{ label: "Total Volume", value: this.fmtMoney(m.volumeTotal) },
|
||||||
|
{ label: "Liquidity", value: this.fmtMoney(m.liquidity) },
|
||||||
|
{ label: "Open Interest", value: this.fmtMoney(m.openInterest) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- trader detail
|
||||||
|
let traderDetail = null;
|
||||||
|
let traderStats = [];
|
||||||
|
if (view === "trader") {
|
||||||
|
const t = traders.find((x) => x.id === this.state.selectedTraderId) || traders[0];
|
||||||
|
if (t) {
|
||||||
|
const { line, area } = this.buildPath(t.pnlSeries, 600, 200, 14);
|
||||||
|
const totalPnl = t.pnlRealized + t.pnlUnrealized;
|
||||||
|
traderDetail = {
|
||||||
|
handle: t.handle,
|
||||||
|
wallet: t.wallet,
|
||||||
|
initial: t.handle[0].toUpperCase(),
|
||||||
|
avatarBg: this.avatarBg(t.id),
|
||||||
|
joined: t.joined,
|
||||||
|
activePositionsLabel: String(t.activePositions),
|
||||||
|
traitChips: t.traits.map((tr) => {
|
||||||
|
const s = this.traitStyle(tr);
|
||||||
|
return { label: tr, bg: s.bg, color: s.color, border: s.border };
|
||||||
|
}),
|
||||||
|
linePath: line,
|
||||||
|
areaPath: area,
|
||||||
|
hasConnections: t.connectedWallets.length > 0,
|
||||||
|
noConnections: t.connectedWallets.length === 0,
|
||||||
|
connections: t.connectedWallets,
|
||||||
|
positions: t.positions.map((p) => ({
|
||||||
|
market: p.market,
|
||||||
|
side: p.side,
|
||||||
|
sideBg: p.side === "Yes" ? "rgba(18,212,138,0.14)" : "rgba(246,70,93,0.14)",
|
||||||
|
sideColor: p.side === "Yes" ? "#12D48A" : "#F6465D",
|
||||||
|
sizeLabel: this.fmtMoney(p.size),
|
||||||
|
entryLabel: p.entry.toFixed(1) + "%",
|
||||||
|
currentLabel: p.current.toFixed(1) + "%",
|
||||||
|
pnlLabel: this.fmtSigned(0, "").slice(0,0) + (p.pnl >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(p.pnl)),
|
||||||
|
pnlColor: p.pnl >= 0 ? "#12D48A" : "#F6465D",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
traderStats = [
|
||||||
|
{ label: "Realized P&L", value: (t.pnlRealized >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlRealized)), color: t.pnlRealized >= 0 ? "#12D48A" : "#F6465D" },
|
||||||
|
{ label: "Unrealized P&L", value: (t.pnlUnrealized >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(t.pnlUnrealized)), color: t.pnlUnrealized >= 0 ? "#12D48A" : "#F6465D" },
|
||||||
|
{ label: "Win Rate", value: t.winRate.toFixed(1) + "%", color: "#EDEFF5" },
|
||||||
|
{ label: "Total Volume", value: this.fmtMoney(t.volume), color: "#EDEFF5" },
|
||||||
|
{ label: "Avg Position", value: this.fmtMoney(t.avgPositionSize), color: "#EDEFF5" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
navItems,
|
||||||
|
headerVolume,
|
||||||
|
breadcrumbItems,
|
||||||
|
isOverview: view === "overview",
|
||||||
|
isMarket: view === "market",
|
||||||
|
isTrader: view === "trader",
|
||||||
|
isTraderSearch: view === "traderSearch",
|
||||||
|
searchResultsCount: String(searchResults.length),
|
||||||
|
searchResults,
|
||||||
|
traitFilterChips,
|
||||||
|
searchText: this.state.searchText,
|
||||||
|
onSearchTextChange: this.setSearchText,
|
||||||
|
winRateSlider: this.buildDualSlider("winRateMin", "winRateMax", 0, 100, (v) => v.toFixed(0) + "%"),
|
||||||
|
volumeSlider: this.buildDualSlider("volumeMin", "volumeMax", 0, 25000000, (v) => this.fmtMoney(v)),
|
||||||
|
pnlSlider: this.buildDualSlider("pnlMin", "pnlMax", -50000, 500000, (v) => (v >= 0 ? "+" : "-") + this.fmtMoney(Math.abs(v))),
|
||||||
|
onResetFilters: this.resetFilters,
|
||||||
|
globalStats,
|
||||||
|
marketRows,
|
||||||
|
leaderboard,
|
||||||
|
marketDetail,
|
||||||
|
marketStats,
|
||||||
|
traderDetail,
|
||||||
|
traderStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Mock data for prediction market analytics prototype
|
||||||
|
|
||||||
|
function genSeries(n, start, vol, drift) {
|
||||||
|
const pts = [start];
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const next = Math.max(2, Math.min(98, pts[i - 1] + (Math.random() - 0.5 + drift) * vol));
|
||||||
|
pts.push(Math.round(next * 10) / 10);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MARKETS = [
|
||||||
|
{
|
||||||
|
id: "fed-sep-cut",
|
||||||
|
question: "Fed cuts rates at September meeting?",
|
||||||
|
category: "Economics",
|
||||||
|
status: "Active",
|
||||||
|
resolves: "Sep 18, 2026",
|
||||||
|
yesPrice: 71.4,
|
||||||
|
change24h: 4.2,
|
||||||
|
volume24h: 2840000,
|
||||||
|
volumeTotal: 41200000,
|
||||||
|
liquidity: 6120000,
|
||||||
|
openInterest: 18400000,
|
||||||
|
traders: 8340,
|
||||||
|
yesSeries: genSeries(40, 55, 4, 0.06),
|
||||||
|
noSeries: null,
|
||||||
|
orderBook: {
|
||||||
|
bids: [[71.0, 182000], [70.5, 240000], [70.0, 310000], [69.5, 198000], [69.0, 145000]],
|
||||||
|
asks: [[71.5, 165000], [72.0, 221000], [72.5, 290000], [73.0, 176000], [73.5, 130000]]
|
||||||
|
},
|
||||||
|
holders: [
|
||||||
|
{ trader: "0xA13f…9c2", side: "Yes", size: 420000, value: 300000 },
|
||||||
|
{ trader: "quant_owl", side: "Yes", size: 318000, value: 227000 },
|
||||||
|
{ trader: "0x77Bd…41a", side: "No", size: 275000, value: 78600 },
|
||||||
|
{ trader: "resolvr", side: "Yes", size: 210000, value: 150000 },
|
||||||
|
{ trader: "0x9F02…c88", side: "No", size: 188000, value: 53700 }
|
||||||
|
],
|
||||||
|
topTraders: ["quant_owl", "resolvr", "arb_meridian", "0xA13f…9c2"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "btc-150k",
|
||||||
|
question: "Bitcoin above $150,000 by year end?",
|
||||||
|
category: "Crypto",
|
||||||
|
status: "Active",
|
||||||
|
resolves: "Dec 31, 2026",
|
||||||
|
yesPrice: 38.2,
|
||||||
|
change24h: -2.8,
|
||||||
|
volume24h: 5120000,
|
||||||
|
volumeTotal: 88900000,
|
||||||
|
liquidity: 11400000,
|
||||||
|
openInterest: 34200000,
|
||||||
|
traders: 21300,
|
||||||
|
yesSeries: genSeries(40, 45, 5, -0.02),
|
||||||
|
noSeries: null,
|
||||||
|
orderBook: {
|
||||||
|
bids: [[38.0, 410000], [37.5, 380000], [37.0, 512000], [36.5, 290000], [36.0, 210000]],
|
||||||
|
asks: [[38.5, 390000], [39.0, 460000], [39.5, 330000], [40.0, 280000], [40.5, 190000]]
|
||||||
|
},
|
||||||
|
holders: [
|
||||||
|
{ trader: "satoshi_fan", side: "Yes", size: 890000, value: 340000 },
|
||||||
|
{ trader: "0x4Ac1…d02", side: "No", size: 720000, value: 445000 },
|
||||||
|
{ trader: "bot_delta_9", side: "No", size: 610000, value: 377000 },
|
||||||
|
{ trader: "arb_meridian", side: "Yes", size: 540000, value: 206000 },
|
||||||
|
{ trader: "0xE221…7b4", side: "Yes", size: 480000, value: 183000 }
|
||||||
|
],
|
||||||
|
topTraders: ["satoshi_fan", "bot_delta_9", "arb_meridian", "0x4Ac1…d02"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ucl-winner",
|
||||||
|
question: "Real Madrid win Champions League 2027?",
|
||||||
|
category: "Sports",
|
||||||
|
status: "Active",
|
||||||
|
resolves: "May 30, 2027",
|
||||||
|
yesPrice: 22.6,
|
||||||
|
change24h: 1.1,
|
||||||
|
volume24h: 980000,
|
||||||
|
volumeTotal: 14300000,
|
||||||
|
liquidity: 3210000,
|
||||||
|
openInterest: 7600000,
|
||||||
|
traders: 5120,
|
||||||
|
yesSeries: genSeries(40, 20, 3, 0.03),
|
||||||
|
noSeries: null,
|
||||||
|
orderBook: {
|
||||||
|
bids: [[22.0, 92000], [21.5, 110000], [21.0, 88000], [20.5, 64000], [20.0, 51000]],
|
||||||
|
asks: [[22.5, 87000], [23.0, 101000], [23.5, 76000], [24.0, 55000], [24.5, 40000]]
|
||||||
|
},
|
||||||
|
holders: [
|
||||||
|
{ trader: "resolvr", side: "Yes", size: 210000, value: 47000 },
|
||||||
|
{ trader: "0xB901…5e3", side: "No", size: 198000, value: 153000 },
|
||||||
|
{ trader: "quant_owl", side: "Yes", size: 155000, value: 35000 },
|
||||||
|
{ trader: "farmer_jo", side: "Yes", size: 121000, value: 27000 }
|
||||||
|
],
|
||||||
|
topTraders: ["resolvr", "quant_owl", "0xB901…5e3"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "us-election-28",
|
||||||
|
question: "Democratic candidate wins 2028 election?",
|
||||||
|
category: "Politics",
|
||||||
|
status: "Active",
|
||||||
|
resolves: "Nov 7, 2028",
|
||||||
|
yesPrice: 54.8,
|
||||||
|
change24h: 0.6,
|
||||||
|
volume24h: 3620000,
|
||||||
|
volumeTotal: 61500000,
|
||||||
|
liquidity: 9870000,
|
||||||
|
openInterest: 28100000,
|
||||||
|
traders: 14700,
|
||||||
|
yesSeries: genSeries(40, 50, 3.5, 0.01),
|
||||||
|
noSeries: null,
|
||||||
|
orderBook: {
|
||||||
|
bids: [[54.5, 260000], [54.0, 310000], [53.5, 220000], [53.0, 190000], [52.5, 140000]],
|
||||||
|
asks: [[55.0, 245000], [55.5, 300000], [56.0, 210000], [56.5, 175000], [57.0, 120000]]
|
||||||
|
},
|
||||||
|
holders: [
|
||||||
|
{ trader: "0x2Ff8…a10", side: "Yes", size: 610000, value: 334000 },
|
||||||
|
{ trader: "arb_meridian", side: "No", size: 540000, value: 244000 },
|
||||||
|
{ trader: "night_owl_88", side: "Yes", size: 410000, value: 224000 },
|
||||||
|
{ trader: "0xC450…9d1", side: "Yes", size: 380000, value: 208000 }
|
||||||
|
],
|
||||||
|
topTraders: ["0x2Ff8…a10", "arb_meridian", "night_owl_88"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "openai-ipo",
|
||||||
|
question: "OpenAI IPOs before end of 2027?",
|
||||||
|
category: "Business",
|
||||||
|
status: "Active",
|
||||||
|
resolves: "Dec 31, 2027",
|
||||||
|
yesPrice: 16.9,
|
||||||
|
change24h: -0.4,
|
||||||
|
volume24h: 740000,
|
||||||
|
volumeTotal: 9800000,
|
||||||
|
liquidity: 2140000,
|
||||||
|
openInterest: 5300000,
|
||||||
|
traders: 3980,
|
||||||
|
yesSeries: genSeries(40, 18, 2.5, -0.01),
|
||||||
|
noSeries: null,
|
||||||
|
orderBook: {
|
||||||
|
bids: [[16.5, 61000], [16.0, 72000], [15.5, 58000], [15.0, 40000], [14.5, 33000]],
|
||||||
|
asks: [[17.0, 55000], [17.5, 63000], [18.0, 47000], [18.5, 36000], [19.0, 28000]]
|
||||||
|
},
|
||||||
|
holders: [
|
||||||
|
{ trader: "bot_delta_9", side: "No", size: 310000, value: 258000 },
|
||||||
|
{ trader: "0x9F02…c88", side: "Yes", size: 190000, value: 32000 },
|
||||||
|
{ trader: "farmer_jo", side: "No", size: 140000, value: 116000 }
|
||||||
|
],
|
||||||
|
topTraders: ["bot_delta_9", "0x9F02…c88"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fifa-worldcup",
|
||||||
|
question: "Brazil wins 2026 FIFA World Cup?",
|
||||||
|
category: "Sports",
|
||||||
|
status: "Resolved · No",
|
||||||
|
resolves: "Jul 19, 2026",
|
||||||
|
yesPrice: 0,
|
||||||
|
change24h: 0,
|
||||||
|
volume24h: 0,
|
||||||
|
volumeTotal: 27600000,
|
||||||
|
liquidity: 0,
|
||||||
|
openInterest: 0,
|
||||||
|
traders: 19200,
|
||||||
|
yesSeries: genSeries(40, 30, 5, 0.1).map((v,i,a)=> i>34 ? Math.max(0, 30 - (i-34)*6) : v),
|
||||||
|
noSeries: null,
|
||||||
|
orderBook: { bids: [], asks: [] },
|
||||||
|
holders: [
|
||||||
|
{ trader: "night_owl_88", side: "No", size: 520000, value: 520000 },
|
||||||
|
{ trader: "0xC450…9d1", side: "Yes", size: 300000, value: 0 }
|
||||||
|
],
|
||||||
|
topTraders: ["night_owl_88", "0xC450…9d1"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRADERS = [
|
||||||
|
{
|
||||||
|
id: "quant_owl",
|
||||||
|
handle: "quant_owl",
|
||||||
|
wallet: "0x1a4F…88bC",
|
||||||
|
traits: ["Arbitrage Bot", "High Volume"],
|
||||||
|
joined: "Mar 2024",
|
||||||
|
pnlRealized: 184200,
|
||||||
|
pnlUnrealized: 32100,
|
||||||
|
winRate: 68.4,
|
||||||
|
volume: 4820000,
|
||||||
|
avgPositionSize: 41200,
|
||||||
|
activePositions: 14,
|
||||||
|
pnlSeries: genSeries(30, 40, 6, 0.08),
|
||||||
|
positions: [
|
||||||
|
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 318000, entry: 62.1, current: 71.4, pnl: 29580, status: "Open" },
|
||||||
|
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 155000, entry: 18.4, current: 22.6, pnl: 6510, status: "Open" },
|
||||||
|
{ market: "US Presidential Election 2028", side: "No", size: 96000, entry: 48.2, current: 45.2, pnl: 2880, status: "Open" },
|
||||||
|
{ market: "Bitcoin above $100k Jan 2026", side: "Yes", size: 210000, entry: 55.0, current: 100, pnl: 94500, status: "Resolved" }
|
||||||
|
],
|
||||||
|
connectedWallets: ["0x88Ac…2f01", "0x5b0D…e410"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bot_delta_9",
|
||||||
|
handle: "bot_delta_9",
|
||||||
|
wallet: "0x5C90…14fA",
|
||||||
|
traits: ["Trading Bot", "Resolution Farming"],
|
||||||
|
joined: "Jan 2025",
|
||||||
|
pnlRealized: 61400,
|
||||||
|
pnlUnrealized: -8200,
|
||||||
|
winRate: 54.1,
|
||||||
|
volume: 12400000,
|
||||||
|
avgPositionSize: 18300,
|
||||||
|
activePositions: 62,
|
||||||
|
pnlSeries: genSeries(30, 30, 3, 0.02),
|
||||||
|
positions: [
|
||||||
|
{ market: "Bitcoin above $150,000 by year end?", side: "No", size: 610000, entry: 41.0, current: 38.2, pnl: 17080, status: "Open" },
|
||||||
|
{ market: "OpenAI IPOs before end of 2027?", side: "No", size: 310000, entry: 84.2, current: 83.1, pnl: 3410, status: "Open" },
|
||||||
|
{ market: "Brazil wins 2026 FIFA World Cup?", side: "No", size: 520000, entry: 71.0, current: 100, pnl: 150800, status: "Resolved" }
|
||||||
|
],
|
||||||
|
connectedWallets: ["0x14fA…C90D", "0x77b2…9e11", "0x0021…aabc"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "arb_meridian",
|
||||||
|
handle: "arb_meridian",
|
||||||
|
wallet: "0x902D…7Ac1",
|
||||||
|
traits: ["Arbitrage Bot"],
|
||||||
|
joined: "Aug 2023",
|
||||||
|
pnlRealized: 402800,
|
||||||
|
pnlUnrealized: 51200,
|
||||||
|
winRate: 71.9,
|
||||||
|
volume: 22100000,
|
||||||
|
avgPositionSize: 68400,
|
||||||
|
activePositions: 9,
|
||||||
|
pnlSeries: genSeries(30, 60, 5, 0.05),
|
||||||
|
positions: [
|
||||||
|
{ market: "Bitcoin above $150,000 by year end?", side: "Yes", size: 540000, entry: 30.5, current: 38.2, pnl: 41580, status: "Open" },
|
||||||
|
{ market: "Democratic candidate wins 2028 election?", side: "No", size: 540000, entry: 47.0, current: 45.2, pnl: 9720, status: "Open" },
|
||||||
|
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 180000, entry: 58.0, current: 71.4, pnl: 24120, status: "Open" }
|
||||||
|
],
|
||||||
|
connectedWallets: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resolvr",
|
||||||
|
handle: "resolvr",
|
||||||
|
wallet: "0xF001…22Cd",
|
||||||
|
traits: ["Human", "Resolution Farming"],
|
||||||
|
joined: "Nov 2024",
|
||||||
|
pnlRealized: 92300,
|
||||||
|
pnlUnrealized: 12800,
|
||||||
|
winRate: 59.7,
|
||||||
|
volume: 6740000,
|
||||||
|
avgPositionSize: 27600,
|
||||||
|
activePositions: 22,
|
||||||
|
pnlSeries: genSeries(30, 35, 4, 0.03),
|
||||||
|
positions: [
|
||||||
|
{ market: "Fed cuts rates at September meeting?", side: "Yes", size: 210000, entry: 60.0, current: 71.4, pnl: 23940, status: "Open" },
|
||||||
|
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 210000, entry: 15.2, current: 22.6, pnl: 15540, status: "Open" }
|
||||||
|
],
|
||||||
|
connectedWallets: ["0x22Cd…F001"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "night_owl_88",
|
||||||
|
handle: "night_owl_88",
|
||||||
|
wallet: "0x3E77…B420",
|
||||||
|
traits: ["Human", "High Volume"],
|
||||||
|
pnlRealized: 128900,
|
||||||
|
pnlUnrealized: 4100,
|
||||||
|
winRate: 62.2,
|
||||||
|
volume: 9120000,
|
||||||
|
avgPositionSize: 35200,
|
||||||
|
activePositions: 11,
|
||||||
|
pnlSeries: genSeries(30, 42, 4, 0.04),
|
||||||
|
positions: [
|
||||||
|
{ market: "Democratic candidate wins 2028 election?", side: "Yes", size: 410000, entry: 51.0, current: 54.8, pnl: 15580, status: "Open" },
|
||||||
|
{ market: "Brazil wins 2026 FIFA World Cup?", side: "No", size: 520000, entry: 68.0, current: 100, pnl: 166400, status: "Resolved" }
|
||||||
|
],
|
||||||
|
connectedWallets: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "farmer_jo",
|
||||||
|
handle: "farmer_jo",
|
||||||
|
wallet: "0xA210…6Bf0",
|
||||||
|
traits: ["Human", "Resolution Farming"],
|
||||||
|
pnlRealized: 21400,
|
||||||
|
pnlUnrealized: -3100,
|
||||||
|
winRate: 48.9,
|
||||||
|
volume: 1840000,
|
||||||
|
avgPositionSize: 9200,
|
||||||
|
activePositions: 31,
|
||||||
|
pnlSeries: genSeries(30, 20, 3, -0.01),
|
||||||
|
positions: [
|
||||||
|
{ market: "Real Madrid win Champions League 2027?", side: "Yes", size: 121000, entry: 24.0, current: 22.6, pnl: -1690, status: "Open" },
|
||||||
|
{ market: "OpenAI IPOs before end of 2027?", side: "No", size: 140000, entry: 80.0, current: 83.1, pnl: -4340, status: "Open" }
|
||||||
|
],
|
||||||
|
connectedWallets: ["0x6Bf0…A210", "0x9021…4Ccb"]
|
||||||
|
}
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 339 KiB |
Reference in New Issue
Block a user