WinForms-Host durch die Avalonia-Shell abgeloest

Merge von main: die dort entstandene Deployment-Center-Integration (Lizenz,
Heartbeat mit DB-Health-Check, Fehler-Stream, UpdateService) ist jetzt Teil
des plattformneutralen Kerns. Predictalytics.WinFormsHost ist entfernt.

Nach Predictalytics.Hosting gezogen:
- DcConfig, DcApiClient, DcErrorSink, DcHeartbeatService, DcUpdateService
  unveraendert - sie waren bereits plattformneutral
- DcErrorReporter ohne Application.ThreadException und MessageBox; der
  UI-Thread-Handler liegt jetzt beim Host und ruft ReportUiThreadException
- LicenseGuard/LicenseSession ohne Dialog und ohne WinForms-Timer. Neu:
  TryUseCachedAsync, ActivateAsync, StartPeriodicRevalidation ueber
  PeriodicTimer. Die Unterscheidung transienter Fehler und die Warnung vor
  ablaufender Gnadenfrist sind unveraendert uebernommen.
- Dc-Einstellungen von AppSettings nach PredictalyticsOptions; die
  Watchdog-Einstellungen entfallen
- DcErrorSink im LoggingSetup, Startbanner nutzt DcConfig.AppVersion

BuildInfo.targets wird jetzt von Predictalytics.Hosting importiert.

In der Avalonia-Shell nachgezogen:
- Menue Deployment Center mit Update-Suche und Lizenzstatus
- Einstellungsgruppe Deployment Center statt Watchdog, Update-Kanal als
  ComboBox, Server-URL nur zur Anzeige
- Heartbeat-Snapshot mit SELECT-1-Probe wie in der WinForms-Fassung
- Update-Pruefung still beim Start und interaktiv ueber das Menue, mit
  NotifyStopping vor dem Start des Update-Agenten
- Lizenzfenster wertet IsTransient aus: bei fehlender Serververbindung wird
  nicht behauptet, die Lizenz sei ungueltig
- TextBox.Watermark auf PlaceholderText (in Avalonia 12 veraltet)

Build: 0 Fehler, 8 Warnungen (alle vorbestehend).
Tests: 100 bestanden, 0 Fehler, 1 uebersprungen.
Verifiziert: --license-status meldet gueltig samt Gnadenfrist; die GUI
startet durch, prueft die Lizenz und laeuft gegen den UpdateService.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-08 21:07:13 +02:00
co-authored by Claude Opus 5
35 changed files with 1694 additions and 1578 deletions
+113
View File
@@ -0,0 +1,113 @@
# Predictalytics
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 `src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj`
(`<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
View File
@@ -7,7 +7,6 @@
<Project Path="src/Predictalytics.Hosting/Predictalytics.Hosting.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" />
</Folder>
</Solution>
+232
View File
@@ -0,0 +1,232 @@
# 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 im `Predictalytics.WinFormsHost` 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.WinFormsHost.csproj` importiert `Deploymentcenter.BuildInfo.targets`
und erzeugt daraus zur Übersetzungszeit `Predictalytics.WinFormsHost.BuildInfo`
mit `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`, `Channel` und
`Summary`. Quelle ist `<Version>` in derselben csproj — 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.
-82
View File
@@ -1,82 +0,0 @@
# 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.
+52
View File
@@ -443,8 +443,60 @@ 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.
+96
View File
@@ -0,0 +1,96 @@
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();
}
+37
View File
@@ -0,0 +1,37 @@
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 &lt;Version&gt; 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
}
@@ -0,0 +1,252 @@
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 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)
{
lock (Sync)
{
_api?.Dispose();
_api = null;
_tokenRejected = false;
if (!enabled || string.IsNullOrWhiteSpace(token))
{
_enabled = false;
return;
}
_api = new DcApiClient(token);
_enabled = true;
}
}
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; }
// Debug level on purpose: a warning here would be logged, land in the sink and
// come straight back as the next report.
Log.Debug("Deployment Center error stream rejected the token ({Code}); reporting disabled.", ex.Code);
}
catch (Exception ex)
{
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] + "…";
}
}
+39
View File
@@ -0,0 +1,39 @@
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);
}
}
@@ -0,0 +1,217 @@
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
};
}
@@ -0,0 +1,58 @@
using Deploymentcenter.Client;
using Serilog;
namespace Predictalytics.Hosting;
/// <summary>
/// Checks the Deployment Center UpdateService for a newer release
/// (GET /api/updateservice/v1/check, no token needed).
///
/// 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.
/// </summary>
public static class DcUpdateService
{
private const string AgentFileName = "update-agent.exe";
public static async Task<UpdateCheckResult> CheckAsync(string channel, CancellationToken ct = default)
{
var client = new UpdateClient();
return await client.CheckForUpdateAsync(
baseUrl: DcConfig.BaseUrl,
projectId: DcConfig.ProductSlug,
currentVersion: DcConfig.AppVersion,
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
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()
{
var path = Path.Combine(AppContext.BaseDirectory, AgentFileName);
return File.Exists(path) ? path : null;
}
/// <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)
{
var agent = FindUpdateAgent();
if (agent is null)
{
Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", AgentFileName);
return false;
}
Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}) und beende die Anwendung...", agent, channel);
return UpdateClient.LaunchUpdateAgent(
agentPath: agent,
projectId: DcConfig.ProductSlug,
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
action: "update",
version: "latest",
exitCurrentApp: true);
}
}
+111 -75
View File
@@ -3,94 +3,105 @@ 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>
/// Lizenzpruefung gegen das Deploymentcenter (dc.mhdf.de).
/// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate).
/// <para>
/// Endpunkt und Produkt-Slug sind bewusst einkompiliert (keine Benutzer-
/// konfiguration): ein konfigurierbarer Endpunkt wuerde es erlauben, die
/// Anwendung auf einen gefaelschten Lizenzserver zu zeigen.
/// 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 keinen Dialog: die interaktive Aktivierung liegt beim Host
/// (Avalonia- bzw. WinForms-Fenster), der Headless-Modus bezieht den
/// Schluessel aus der Umgebungsvariable oder dem verschluesselten Cache.
/// 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>In dc_projects hinterlegter Slug.</summary>
public const string ProductSlug = "predictalytics";
private const string ServerUrl = "https://dc.mhdf.de";
/// <summary>Re-check interval while the app is running (12 h).</summary>
public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12);
private static readonly LicenseClient Client = new();
public static LicenseClient CreateClient() => new();
/// <summary>Hardware-ID v2 dieser Maschine (Format <c>2:&lt;plattform&gt;:&lt;hex&gt;</c>).</summary>
public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(ProductSlug);
/// <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(ProductSlug);
public static string StorageDirectory => LicenseConfig.GetStorageDirectory(DcConfig.ProductSlug);
/// <summary>
/// Zwischengespeicherter Lizenzschluessel, oder null wenn noch nie aktiviert wurde.
/// Der Cache ist AES-GCM-verschluesselt und an Hardware-ID und Produkt gebunden.
/// 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 string? GetCachedLicenseKey()
public static async Task<CachedLicenseCheck> TryUseCachedAsync(LicenseClient? client = null)
{
try
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))
{
var cache = StateStore.Load(ProductSlug, GetHardwareInfo().HardwareId);
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
return new CachedLicenseCheck(null, null);
}
catch (Exception ex)
var result = await client.ValidateAsync(DcConfig.ProductSlug, cachedKey!, DcConfig.BaseUrl);
if (result.IsValid)
{
Log.Debug(ex, "Lizenz-Cache konnte nicht gelesen werden");
return null;
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>
/// Validiert einen Lizenzschluessel am Server und legt ihn bei Erfolg
/// verschluesselt ab. Bei Netzwerkfehlern faellt der Client auf den
/// Cache zurueck (Status <c>valid_offline</c>).
/// 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 Task<LicenseValidationResult> ValidateAsync(string licenseKey) =>
Client.ValidateAsync(ProductSlug, Normalize(licenseKey), ServerUrl);
/// <summary>
/// Validiert mit dem zwischengespeicherten Schluessel.
/// Liefert null, wenn noch keiner hinterlegt ist.
/// </summary>
public static async Task<LicenseValidationResult?> ValidateCachedAsync()
{
var key = GetCachedLicenseKey();
return key == null ? null : await ValidateAsync(key);
}
/// <summary>Gibt den Aktivierungsplatz dieser Maschine am Server wieder frei.</summary>
public static async Task<bool> DeactivateAsync()
{
var key = GetCachedLicenseKey();
if (key == null) return false;
return await Client.DeactivateAsync(ProductSlug, key, ServerUrl);
}
/// <summary>Schreibweise vereinheitlichen — die Schluessel sind durchgaengig gross.</summary>
public static string Normalize(string licenseKey) => licenseKey.Trim().ToUpperInvariant();
/// <summary>
/// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf und
/// Ablauf waehrend des Betriebs.
/// </summary>
/// <param name="onUnusable">
/// Wird genau einmal aufgerufen, wenn die Lizenz endgueltig nicht mehr nutzbar ist.
/// Der Host entscheidet, was dann passiert. Netzwerkfehler loesen den Rueckruf
/// nicht aus — dafuer gibt es den verschluesselten Offline-Cache des Clients.
/// </param>
public static IDisposable StartPeriodicRevalidation(Action<LicenseValidationResult> onUnusable)
public static IDisposable StartPeriodicRevalidation(
LicenseSession session, Action<LicenseValidationResult> onUnusable)
{
var cts = new CancellationTokenSource();
@@ -103,28 +114,37 @@ public static class LicenseGuard
{
try
{
var result = await ValidateCachedAsync();
if (result == null)
{
Log.Warning("Lizenz-Revalidierung: kein zwischengespeicherter Schlüssel vorhanden.");
continue;
}
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 — Lizenz gilt über den lokalen Cache weiter ({Status}).",
result.Status);
Log.Warning("Lizenzserver nicht erreichbar — Prüfung erfolgte aus dem Offline-Cache.");
}
WarnIfGraceRunningOut(result);
continue;
}
// network_error heisst: Server weg UND kein brauchbarer Cache.
// Das ist kein Widerruf — beim naechsten Durchlauf erneut versuchen.
if (result.Status.Equals("network_error", StringComparison.OrdinalIgnoreCase))
if (result.IsTransient)
{
Log.Warning("Lizenz-Revalidierung fehlgeschlagen (Netzwerk): {Message}", result.Message);
// 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;
}
@@ -135,7 +155,7 @@ public static class LicenseGuard
}
catch (Exception ex)
{
// Eine unerwartete Ausnahme darf die Anwendung niemals beenden.
// Never kill the app from an unexpected exception here.
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
}
}
@@ -149,6 +169,22 @@ public static class LicenseGuard
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()
+7 -2
View File
@@ -45,7 +45,12 @@ public static class LoggingSetup
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
// ── Console (simple) ──
.WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning);
.WriteTo.Console(outputTemplate: SimpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
// ── Deployment Center error stream (Error/Fatal) ──
// Wird hier registriert, lange bevor die Einstellungen bekannt sind: der Sink
// fragt DcErrorReporter zur Sendezeit, ob das Melden eingeschaltet ist.
.WriteTo.Sink(new DcErrorSink(), restrictedToMinimumLevel: LogEventLevel.Error);
// ── Terminalanzeige des Hosts (optional) ──
if (terminalSink != null)
@@ -152,7 +157,7 @@ public static class LoggingSetup
public static void LogStartupBanner()
{
Log.Warning("══════════════════════════════════════════════════════");
Log.Warning(" 🚀 Predictalytics v1.0 — Data retrieval started!");
Log.Warning(" 🚀 Predictalytics v{Version} — Data retrieval started!", DcConfig.AppVersion);
Log.Warning(" 📊 First platform report in 5 minutes.");
Log.Warning("══════════════════════════════════════════════════════");
}
@@ -2,6 +2,9 @@
<PropertyGroup>
<RootNamespace>Predictalytics.Hosting</RootNamespace>
<!-- Wird als current_version an den UpdateService und als build an den Fehler-Stream
gemeldet. Beim Release hier hochziehen. -->
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -29,8 +32,12 @@
<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 LicenseLabrador ab (license.mhdf.de -> dc.mhdf.de). -->
Loest Watchdog + LicenseLabrador ab (Lizenz, UpdateService, Fehler-Stream, Bugtracker). -->
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
<!-- 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>
@@ -8,8 +8,8 @@ namespace Predictalytics.Hosting;
/// Laufzeit-Konfiguration der Anwendung.
/// <para>
/// Die <see cref="System.ComponentModel"/>-Attribute sind plattformneutral (Teil der
/// Basisbibliothek) und werden vom WinForms-PropertyGrid ausgewertet. Die Avalonia-Shell
/// kann sie fuer generierte Beschriftungen nutzen oder ignorieren.
/// Basisbibliothek). Die Avalonia-Shell nutzt sie fuer Beschriftungen und Hilfetexte
/// der Einstellungsansicht.
/// </para>
/// </summary>
public sealed class PredictalyticsOptions
@@ -47,43 +47,62 @@ public sealed class PredictalyticsOptions
"Beispiel: prox-1|Proxy|http://user:pass@proxy:8080\nip-1|SourceIp|192.168.1.100")]
public string EgressChannelsText { get => _egressChannelsText; set => _egressChannelsText = value ?? ""; }
// ─── Watchdog ───
// ─── Deployment Center ───
[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")]
[Category("Deployment Center")]
[DisplayName("Server URL")]
[Description("Basis-URL des Watchdog-Servers.")]
[DefaultValue("https://watchdog.mhdf.de")]
public string WatchdogUrl { get; set; } = "https://watchdog.mhdf.de";
[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("Watchdog")]
[DisplayName("API Key")]
[Description("Shared Key oder Agent-Token des Watchdog-Servers (X-Watchdog-Key). Ohne Key werden keine Heartbeats gesendet.")]
[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 WatchdogApiKey { get; set; } = "";
public string DcToken { get; set; } = "";
[Category("Watchdog")]
[DisplayName("Source")]
[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 WatchdogSource { get; set; } = "Predictalytics";
public string DcSource { get; set; } = "Predictalytics";
[Category("Watchdog")]
[DisplayName("Instance")]
[Category("Deployment Center")]
[DisplayName("Monitor Instance")]
[Description("Instanz-Kennung, falls mehrere Predictalytics-Instanzen laufen.")]
[DefaultValue("default")]
public string WatchdogInstance { get; set; } = "default";
public string DcInstance { 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.")]
[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 WatchdogIntervalSeconds { get; set; } = 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 ───
@@ -1,141 +0,0 @@
using System.Text;
using System.Text.Json;
using Serilog;
namespace Predictalytics.Hosting;
/// <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();
}
}
+46 -14
View File
@@ -42,31 +42,50 @@ public partial class App : global::Avalonia.Application
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
var result = await LicenseGuard.ValidateCachedAsync();
// 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();
if (result?.IsValid != true)
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(result);
var licenseWindow = new LicenseWindow(client, check.LastResult);
desktop.MainWindow = licenseWindow;
licenseWindow.Show();
if (!await licenseWindow.Completion)
session = await licenseWindow.Completion;
if (session is null)
{
desktop.Shutdown();
return;
}
}
viewModel.License = session;
// ─── Hauptfenster ───
var viewModel = new MainWindowViewModel();
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);
LoggingSetup.LogStartupBanner();
desktop.MainWindow = window;
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
window.Show();
@@ -74,7 +93,7 @@ public partial class App : global::Avalonia.Application
viewModel.Start();
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(unusable =>
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(session, unusable =>
Dispatcher.UIThread.Post(async () =>
{
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
@@ -88,4 +107,17 @@ public partial class App : global::Avalonia.Application
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;
};
}
}
@@ -24,25 +24,32 @@ internal static class HeadlessRunner
// 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
{
if (!await EnsureLicensedAsync()) return ExitNoLicense;
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(result =>
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();
});
using var watchdog = StartWatchdog(options, host);
heartbeat = StartHeartbeat(options, host);
await host.StartWebServerAsync();
Log.Information("WebUI erreichbar unter {Url}", options.WebserverUrl);
@@ -50,13 +57,14 @@ internal static class HeadlessRunner
// Laeuft, bis abgebrochen wird.
await host.StartWorkersAsync(cts.Token);
watchdog?.NotifyStopping();
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;
}
@@ -67,6 +75,7 @@ internal static class HeadlessRunner
}
finally
{
heartbeat?.Dispose();
await Log.CloseAndFlushAsync();
}
}
@@ -75,15 +84,12 @@ internal static class HeadlessRunner
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
/// </summary>
private static async Task<bool> EnsureLicensedAsync()
private static async Task<LicenseSession?> EnsureLicensedAsync()
{
var result = await LicenseGuard.ValidateCachedAsync();
if (result?.IsValid == true)
{
Log.Information("Lizenz gültig ({Status}{Cached}).",
result.Status, result.IsCached ? ", aus lokalem Cache" : "");
return true;
}
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))
@@ -91,44 +97,76 @@ internal static class HeadlessRunner
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.",
result?.Status ?? "kein Schlüssel hinterlegt", LicenseKeyVariable);
return false;
}
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
result = await LicenseGuard.ValidateAsync(key);
if (result.IsValid)
{
Log.Information("Lizenz aktiviert ({Status}).", result.Status);
return true;
}
Log.Fatal("Aktivierung fehlgeschlagen ({Status}): {Message}", result.Status, result.Message);
return false;
}
private static WatchdogHeartbeatService? StartWatchdog(PredictalyticsOptions options, PredictalyticsHost host)
{
if (!options.WatchdogEnabled) return null;
if (string.IsNullOrWhiteSpace(options.WatchdogApiKey) || string.IsNullOrWhiteSpace(options.WatchdogUrl))
{
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen.");
check.LastResult?.Status ?? "kein Schlüssel hinterlegt", LicenseKeyVariable);
return null;
}
var watchdog = new WatchdogHeartbeatService(
options.WatchdogUrl,
options.WatchdogApiKey,
options.WatchdogSource,
options.WatchdogInstance,
options.WatchdogIntervalSeconds,
metadataProvider: () => new
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
var (session, result) = await LicenseGuard.ActivateAsync(client, key);
if (session is not null)
{
workersRunning = host.WorkersRunning,
webserverRunning = host.WebServerRunning
});
watchdog.Start();
return watchdog;
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>
+41 -22
View File
@@ -1,3 +1,4 @@
using Deploymentcenter.Client;
using Predictalytics.Hosting;
namespace Predictalytics.Shell.Services;
@@ -18,26 +19,30 @@ internal static class LicenseCli
public static async Task<int> RunAsync(string[] args)
{
switch (args[0])
LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
return args[0] switch
{
case StatusSwitch: return await ShowStatusAsync();
case SetKeySwitch: return await SetKeyAsync(args.Length > 1 ? args[1] : null);
case DeactivateSwitch: return await DeactivateAsync();
default: return 1;
}
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 : {LicenseGuard.ProductSlug}");
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 = LicenseGuard.GetCachedLicenseKey();
if (cachedKey == null)
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
if (string.IsNullOrWhiteSpace(cachedKey))
{
Console.WriteLine(" Schlüssel : keiner hinterlegt");
Console.WriteLine();
@@ -45,16 +50,28 @@ internal static class LicenseCli
return HeadlessRunner.ExitNoLicense;
}
Console.WriteLine($" Schlüssel : {Mask(cachedKey)}");
Console.WriteLine($" Schlüssel : {Mask(cachedKey!)}");
Console.WriteLine();
Console.Write("Prüfe am Server… ");
Console.Write("Prüfe am Deployment Center… ");
var result = await LicenseGuard.ValidateCachedAsync();
Console.WriteLine(result is { IsValid: true }
? $"gültig ({result.Status}{(result.IsCached ? ", aus lokalem Cache" : "")})"
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 result is { IsValid: true } ? HeadlessRunner.ExitOk : HeadlessRunner.ExitNoLicense;
return HeadlessRunner.ExitNoLicense;
}
private static async Task<int> SetKeyAsync(string? key)
@@ -65,10 +82,10 @@ internal static class LicenseCli
return HeadlessRunner.ExitFailed;
}
Console.Write($"Aktiviere {Mask(LicenseGuard.Normalize(key))} … ");
var result = await LicenseGuard.ValidateAsync(key);
Console.Write($"Aktiviere {Mask(key.Trim().ToUpperInvariant())} … ");
var (session, result) = await LicenseGuard.ActivateAsync(LicenseGuard.CreateClient(), key);
if (result.IsValid)
if (session is not null)
{
Console.WriteLine($"erfolgreich ({result.Status})");
Console.WriteLine($"Hardware-ID: {result.HardwareId}");
@@ -81,14 +98,16 @@ internal static class LicenseCli
private static async Task<int> DeactivateAsync()
{
if (LicenseGuard.GetCachedLicenseKey() == null)
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 Server frei… ");
var ok = await LicenseGuard.DeactivateAsync();
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;
}
@@ -19,7 +19,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
private readonly PredictalyticsHost _host;
private CancellationTokenSource? _workerCts;
private WatchdogHeartbeatService? _watchdog;
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; }
@@ -32,25 +33,32 @@ public sealed partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _dbSizeText = "DB Size: —";
[ObservableProperty] private string _buildVersionText = "Build: —";
[ObservableProperty] private string _buildVersionText = "";
[ObservableProperty] private string _serverButtonText = "▶ Start Server";
[ObservableProperty] private string _webserverButtonText = "▶ Start Webserver";
[ObservableProperty] private bool _isBusy;
public MainWindowViewModel()
/// <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 = PredictalyticsOptions.Load();
Options = options;
_host = new PredictalyticsHost(Options);
_host.StateChanged += () => Dispatcher.UIThread.Post(UpdateStatus);
try
{
var buildDate = new FileInfo(GetType().Assembly.Location).LastWriteTime;
BuildVersionText = $"Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
BuildVersionText = $"v{DcConfig.AppVersion} — Build: {buildDate:yyyy-MM-dd HH:mm:ss}";
}
catch
{
BuildVersionText = "Build: Unknown";
BuildVersionText = $"v{DcConfig.AppVersion}";
}
UpdateStatus();
@@ -69,7 +77,12 @@ public sealed partial class MainWindowViewModel : ObservableObject
dbSizeTimer.Tick += async (_, _) => await RefreshDbSizeAsync();
dbSizeTimer.Start();
RestartWatchdog();
RestartHeartbeat();
if (Options.DcUpdateCheckEnabled)
{
_ = CheckForUpdatesAsync(silent: true);
}
}
/// <summary>Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen.</summary>
@@ -92,33 +105,167 @@ public sealed partial class MainWindowViewModel : ObservableObject
WebserverButtonText = _host.WebServerRunning ? "⏹ Stop Webserver" : "▶ Start Webserver";
}
// ─── Watchdog ───
// ─── Deployment Center: Heartbeat ───
private void RestartWatchdog()
private void RestartHeartbeat()
{
_watchdog?.Dispose();
_watchdog = null;
_heartbeat?.Dispose();
_heartbeat = null;
if (!Options.WatchdogEnabled) return;
if (!Options.DcHeartbeatEnabled) return;
if (string.IsNullOrWhiteSpace(Options.WatchdogApiKey) || string.IsNullOrWhiteSpace(Options.WatchdogUrl))
if (string.IsNullOrWhiteSpace(Options.DcToken))
{
Log.Information("🐕 Watchdog ist aktiviert, aber URL/API Key fehlen — bitte in den Einstellungen eintragen.");
Log.Information("🐕 Heartbeat ist aktiviert, aber es fehlt das Deployment-Center-Token — bitte in den Einstellungen eintragen.");
return;
}
_watchdog = new WatchdogHeartbeatService(
Options.WatchdogUrl,
Options.WatchdogApiKey,
Options.WatchdogSource,
Options.WatchdogInstance,
Options.WatchdogIntervalSeconds,
metadataProvider: () => new
_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)
{
workersRunning = _host.WorkersRunning,
webserverRunning = _host.WebServerRunning
});
_watchdog.Start();
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);
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();
DcUpdateService.LaunchAgent(Options.DcUpdateChannel);
}
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 ───
@@ -177,7 +324,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
private void SaveSettings()
{
Options.Save();
RestartWatchdog();
DcErrorReporter.Configure(Options.DcToken, Options.DcErrorReportingEnabled);
RestartHeartbeat();
Log.Information("Einstellungen gespeichert: {Path}", PredictalyticsOptions.SettingsFilePath);
UpdateStatus();
}
@@ -281,6 +429,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
private async Task RefreshDbSizeAsync()
{
var sizeMb = await _host.GetDatabaseSizeMbAsync();
_lastDbSizeMb = sizeMb;
DbSizeText = sizeMb.HasValue ? $"DB Size: {sizeMb.Value:F2} MB" : "DB Size: —";
}
@@ -300,12 +449,12 @@ public sealed partial class MainWindowViewModel : ObservableObject
}
}
/// <summary>Beim Beenden: Watchdog abmelden, Worker und Webserver stoppen.</summary>
/// <summary>Beim Beenden: Monitor abmelden, Worker und Webserver stoppen.</summary>
public void Shutdown()
{
_watchdog?.NotifyStopping();
_watchdog?.Dispose();
_watchdog = null;
_heartbeat?.NotifyStopping();
_heartbeat?.Dispose();
_heartbeat = null;
_workerCts?.Cancel();
try { _host.StopWebServerAsync().GetAwaiter().GetResult(); } catch { /* beendet sich ohnehin */ }
}
@@ -16,4 +16,7 @@ public static class OptionSources
MySqlSslMode.Preferred,
MySqlSslMode.Required
];
/// <summary>Release-Kanaele des UpdateService.</summary>
public static string[] UpdateChannels { get; } = ["prod", "beta", "dev"];
}
@@ -17,7 +17,7 @@
<TextBox Name="KeyBox"
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
FontSize="14"
Watermark="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" />
<TextBlock Name="StatusText" TextWrapping="Wrap" MinHeight="34" IsVisible="False" />
@@ -8,20 +8,23 @@ namespace Predictalytics.Shell.Views;
/// <summary>
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
/// <see cref="Completion"/> liefert true, sobald eine gueltige Lizenz vorliegt;
/// false, wenn der Benutzer abbricht.
/// <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 TaskCompletionSource<bool> _completion = new();
private readonly LicenseClient _client = null!;
private readonly TaskCompletionSource<LicenseSession?> _completion = new();
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
public Task<bool> Completion => _completion.Task;
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();
@@ -33,12 +36,19 @@ public partial class LicenseWindow : Window
}
}
public LicenseWindow(LicenseValidationResult? lastResult) : this()
public LicenseWindow(LicenseClient client, LicenseValidationResult? lastResult) : this()
{
if (lastResult != null && !string.IsNullOrWhiteSpace(lastResult.Message))
{
SetStatus($"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}", isError: true);
}
_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)
@@ -52,14 +62,14 @@ public partial class LicenseWindow : Window
ActivateButton.IsEnabled = false;
Busy.IsVisible = true;
SetStatus("Prüfe Lizenz am Server…", isError: false);
SetStatus("Prüfe Lizenz am Deployment Center…", isError: false);
try
{
var result = await LicenseGuard.ValidateAsync(key);
if (result.IsValid)
var (session, result) = await LicenseGuard.ActivateAsync(_client, key);
if (session is not null)
{
_completion.TrySetResult(true);
_completion.TrySetResult(session);
Close();
return;
}
@@ -79,14 +89,14 @@ public partial class LicenseWindow : Window
private void OnExitClick(object? sender, RoutedEventArgs e)
{
_completion.TrySetResult(false);
_completion.TrySetResult(null);
Close();
}
protected override void OnClosed(EventArgs e)
{
// Schliessen ueber das Fensterkreuz zaehlt als Abbruch.
_completion.TrySetResult(false);
_completion.TrySetResult(null);
base.OnClosed(e);
}
+49 -17
View File
@@ -49,6 +49,10 @@
<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 ─── -->
@@ -153,34 +157,62 @@
</StackPanel>
</HeaderedContentControl>
<!-- Watchdog -->
<HeaderedContentControl Classes="group" Header="Watchdog">
<!-- 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="Aktiv" />
<CheckBox Grid.Column="1" IsChecked="{Binding WatchdogEnabled}"
<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="Server-URL" />
<TextBox Grid.Column="1" Text="{Binding WatchdogUrl}" />
<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="API Key" />
<TextBox Grid.Column="1" Text="{Binding WatchdogApiKey}" PasswordChar="•" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Source" />
<TextBox Grid.Column="1" Text="{Binding WatchdogSource}" />
</Grid>
<Grid ColumnDefinitions="200,*">
<TextBlock Grid.Column="0" Classes="label" Text="Instanz" />
<TextBox Grid.Column="1" Text="{Binding WatchdogInstance}" />
<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.)" />
<NumericUpDown Grid.Column="1" Value="{Binding WatchdogIntervalSeconds}"
<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>
-274
View File
@@ -1,274 +0,0 @@
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;
}
-320
View File
@@ -1,320 +0,0 @@
using Predictalytics.Hosting;
using Serilog;
namespace Predictalytics.WinFormsHost;
public partial class MainForm : Form
{
private PredictalyticsHost _host = null!;
private CancellationTokenSource? _workerCts;
private PredictalyticsOptions _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 application host.
/// </summary>
public void Initialize()
{
_settings = PredictalyticsOptions.Load();
// Der Host haelt dieselbe Options-Instanz — Aenderungen im PropertyGrid
// wirken damit ohne weitere Weitergabe beim naechsten Start.
_host = new PredictalyticsHost(_settings);
_host.StateChanged += () => BeginInvoke(UpdateStatusBar);
pg_settings.SelectedObject = _settings;
pg_settings.PropertyValueChanged += (s, e) =>
{
_settings.Save();
RestartWatchdog();
};
// 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 {Url}", _settings.WebserverUrl);
Log.Information("Settings: {Path}", PredictalyticsOptions.SettingsFilePath);
_ = 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 = _host.WorkersRunning,
webserverRunning = _host.WebServerRunning
});
_watchdog.Start();
}
private async void Btn_serverstart_Click(object? sender, EventArgs e)
{
if (!_host.WorkersRunning)
{
// Start workers
_workerCts = new CancellationTokenSource();
btn_serverstart.Text = "⏹ Stop Server";
Log.Information("🚀 Starting background workers...");
try
{
await _host.StartWorkersAsync(_workerCts.Token);
}
catch (OperationCanceledException) { }
catch (Exception ex) { Log.Error(ex, "Worker error"); }
}
else
{
// Stop workers
Log.Information("⏹ Stopping background workers...");
_workerCts?.Cancel();
btn_serverstart.Text = "▶ Start Server";
Log.Information("Workers stopped.");
}
UpdateStatusBar();
}
private async void Btn_localWebserver_Click(object? sender, EventArgs e)
{
if (!_host.WebServerRunning)
{
try
{
Log.Information("🌐 Starting embedded Kestrel webserver on {Url}...", _settings.WebserverUrl);
await _host.StartWebServerAsync();
btn_localWebserver.Text = "⏹ Stop Webserver";
Log.Information("✅ WebUI available at {Url}", _settings.WebserverUrl);
Log.Information("📄 Swagger API docs at {Url}/swagger", _settings.WebserverUrl);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to start webserver");
}
}
else
{
Log.Information("⏹ Stopping webserver...");
await _host.StopWebServerAsync();
btn_localWebserver.Text = "▶ Start Webserver";
Log.Information("Webserver stopped.");
}
UpdateStatusBar();
}
private void UpdateStatusBar()
{
var workerStatus = _host.WorkersRunning ? "[RUNNING] Workers" : "[STOPPED] Workers";
var serverStatus = _host.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();
_host?.StopWebServerAsync().GetAwaiter().GetResult();
base.OnFormClosing(e);
}
private void btn_openbrowser_Click(object sender, EventArgs e)
{
try
{
OpenInShell(_settings.WebserverUrl);
}
catch (Exception ex)
{
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 = LoggingSetup.DefaultLogDirectory;
OpenInShell(Directory.Exists(logPath) ? logPath : Environment.CurrentDirectory);
}
catch (Exception ex)
{
Log.Error(ex, "Fehler beim Öffnen des Log-Ordners");
}
}
/// <summary>
/// Oeffnet Pfad oder URL mit der Standardanwendung. UseShellExecute funktioniert
/// unter Windows wie unter Linux (dort ueber xdg-open) — im Gegensatz zum
/// vorherigen direkten Aufruf von explorer.exe.
/// </summary>
private static void OpenInShell(string target) =>
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true });
private async void syncMarketsaToolStripMenuItem_Click(object? sender, EventArgs e)
{
if (_host.WorkersRunning)
{
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 _host.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 (_host.WorkersRunning)
{
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 _host.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 (_host.WorkersRunning)
{
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 _host.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()
{
var sizeMb = await _host.GetDatabaseSizeMbAsync();
if (IsDisposed) return;
this.Invoke(() => label_dbSize.Text = sizeMb.HasValue
? $"DB Size: {sizeMb.Value:F2} MB"
: "DB Size: —");
}
}
@@ -1,129 +0,0 @@
<?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>
@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- Ueberschreibt Directory.Build.props: WinForms braucht das Windows-Desktop-Pack.
Entfaellt, sobald die Avalonia-Shell den WinForms-Host abloest. -->
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>Predictalytics.WinFormsHost</RootNamespace>
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
<ApplicationVisualStyles>true</ApplicationVisualStyles>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<!-- Direkt genutzt: Log.* in Program.cs und MainForm.cs -->
<PackageReference Include="Serilog" />
<!-- Fuer 'dotnet ef' mit diesem Projekt als Startprojekt -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</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 — ersetzt die fruehere Pfad-Heuristik. -->
<Content Include="..\Predictalytics.Api\wwwroot\**"
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</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>
@@ -1,47 +0,0 @@
using Predictalytics.Hosting;
using Predictalytics.WinFormsHost.Services;
using Serilog;
namespace Predictalytics.WinFormsHost;
internal static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
// ─── License gate: no usable license, no app ───
if (!LicenseGate.EnsureLicensed())
{
return;
}
var mainForm = new MainForm();
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier wird lediglich der
// Terminal-Sink beigesteuert, der auf den UI-Thread marshallt.
LoggingSetup.Configure(TerminalHelper.CreateWriteAction(mainForm.Terminal, mainForm));
LoggingSetup.LogStartupBanner();
mainForm.Initialize();
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(result =>
{
if (mainForm.IsDisposed) return;
mainForm.BeginInvoke(() =>
{
MessageBox.Show(
$"Die Lizenz ist nicht mehr gültig ({result.Status}):\n{result.Message}\n\nPredictalytics wird beendet.",
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
System.Windows.Forms.Application.Exit();
});
});
System.Windows.Forms.Application.Run(mainForm);
Log.Information("Application shutting down.");
Log.CloseAndFlush();
}
}
@@ -1,12 +0,0 @@
{
"profiles": {
"Predictalytics.WinFormsHost": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62271;http://localhost:62272"
}
}
}
@@ -1,136 +0,0 @@
using Deploymentcenter.Client;
using Predictalytics.Hosting;
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 validation against the Deploymentcenter.
/// </summary>
public sealed class LicenseDialog : Form
{
private readonly Label _lblStatus;
private readonly TextBox _txtKey;
private readonly Button _btnActivate;
private readonly Button _btnExit;
public LicenseDialog(LicenseValidationResult? lastResult)
{
Text = "Predictalytics — Lizenzaktivierung";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
ClientSize = new Size(480, 250);
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(456, 34)
};
_txtKey = new TextBox
{
Location = new Point(12, 52),
Size = new Size(456, 26),
Font = new Font("Consolas", 11f),
CharacterCasing = CharacterCasing.Upper
};
_lblStatus = new Label
{
Location = new Point(12, 84),
Size = new Size(456, 50),
ForeColor = Color.Firebrick,
Text = FormatInitialStatus(lastResult)
};
// Die Aktivierung wird am Server an diese Hardware-ID gebunden.
var lblHardwareId = new Label
{
Location = new Point(12, 140),
Size = new Size(456, 60),
ForeColor = Color.Gray,
Font = new Font("Consolas", 7.5f),
Text = FormatHardwareId()
};
_btnActivate = new Button
{
Text = "Aktivieren",
Location = new Point(262, 206),
Size = new Size(100, 30)
};
_btnActivate.Click += async (_, _) => await ActivateAsync();
_btnExit = new Button
{
Text = "Beenden",
Location = new Point(368, 206),
Size = new Size(100, 30),
DialogResult = DialogResult.Cancel
};
AcceptButton = _btnActivate;
CancelButton = _btnExit;
Controls.AddRange([lblInfo, _txtKey, _lblStatus, lblHardwareId, _btnActivate, _btnExit]);
}
private static string FormatInitialStatus(LicenseValidationResult? lastResult)
{
if (lastResult == null || string.IsNullOrWhiteSpace(lastResult.Message)) return "";
return $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}";
}
private static string FormatHardwareId()
{
try
{
var hw = LicenseGuard.GetHardwareInfo();
return $"Hardware-ID: {hw.HardwareId}\nQuelle: {hw.HwidSource}";
}
catch (Exception ex)
{
return $"Hardware-ID nicht ermittelbar: {ex.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 LicenseGuard.ValidateAsync(key);
if (result.IsValid)
{
DialogResult = DialogResult.OK;
Close();
return;
}
_lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}";
}
catch (Exception ex)
{
_lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = $"Fehler bei der Prüfung: {ex.Message}";
}
finally
{
_btnActivate.Enabled = true;
}
}
}
@@ -1,27 +0,0 @@
using Predictalytics.Hosting;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// WinForms-seitige Lizenzschranke: verbindet den plattformneutralen
/// <see cref="LicenseGuard"/> mit dem interaktiven Aktivierungsdialog.
/// </summary>
internal static class LicenseGate
{
/// <summary>
/// Blockiert, bis eine nutzbare Lizenz vorliegt. Zuerst wird der zwischengespeicherte
/// Schluessel geprueft; erst wenn der nicht (mehr) nutzbar ist, erscheint der Dialog.
/// Liefert false, wenn der Benutzer abbricht — die Anwendung muss dann beendet werden.
/// </summary>
public static bool EnsureLicensed()
{
var result = LicenseGuard.ValidateCachedAsync().GetAwaiter().GetResult();
if (result?.IsValid == true)
{
return true;
}
using var dialog = new LicenseDialog(result);
return dialog.ShowDialog() == DialogResult.OK;
}
}
@@ -1,52 +0,0 @@
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,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Predictalytics.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:asm.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
@@ -1,48 +0,0 @@
{
"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
}
}