diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d695b3 --- /dev/null +++ b/CLAUDE.md @@ -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= +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= +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= +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` +(``) 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 diff --git a/Predictalytics.slnx b/Predictalytics.slnx index 4ea0f1a..ac2a9c8 100644 --- a/Predictalytics.slnx +++ b/Predictalytics.slnx @@ -7,7 +7,6 @@ - diff --git a/docs/BETRIEB-Deploymentcenter.md b/docs/BETRIEB-Deploymentcenter.md new file mode 100644 index 0000000..2016422 --- /dev/null +++ b/docs/BETRIEB-Deploymentcenter.md @@ -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 " 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:` 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 `` in derselben csproj — beim Release dort +hochziehen und mit + +``` +pack-and-deploy --project predictalytics --version --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. diff --git a/docs/BETRIEB-Watchdog-Lizenz.md b/docs/BETRIEB-Watchdog-Lizenz.md deleted file mode 100644 index 1d131a1..0000000 --- a/docs/BETRIEB-Watchdog-Lizenz.md +++ /dev/null @@ -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. diff --git a/docs/PLAN-Linux-Portierung.md b/docs/PLAN-Linux-Portierung.md index b310855..d6d65d7 100644 --- a/docs/PLAN-Linux-Portierung.md +++ b/docs/PLAN-Linux-Portierung.md @@ -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. diff --git a/src/Predictalytics.Hosting/DcApiClient.cs b/src/Predictalytics.Hosting/DcApiClient.cs new file mode 100644 index 0000000..c6eacc7 --- /dev/null +++ b/src/Predictalytics.Hosting/DcApiClient.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Predictalytics.Hosting; + +/// +/// Failed Deployment Center call. is the stable, machine readable +/// error code from the API envelope ("unauthorized", "rate_limited", ...) — react to it, +/// not to the message text. +/// +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; } + + /// True for errors that repeating the same call cannot fix (wrong/missing token). + 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] + "…"; +} + +/// +/// 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). +/// +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 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; + } + + /// Reads error.code out of {"status":"error","error":{"code":"…"}}. + 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(); +} diff --git a/src/Predictalytics.Hosting/DcConfig.cs b/src/Predictalytics.Hosting/DcConfig.cs new file mode 100644 index 0000000..c4b8866 --- /dev/null +++ b/src/Predictalytics.Hosting/DcConfig.cs @@ -0,0 +1,37 @@ +namespace Predictalytics.Hosting; + +/// +/// 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. +/// +public static class DcConfig +{ + public const string BaseUrl = "https://dc.mhdf.de"; + + /// Slug in dc_projects — license, UpdateService, error stream and bugtracker share it. + public const string ProductSlug = "predictalytics"; + + /// + /// Reported to the license activation list, the monitor and the error stream. + /// Generated by Deploymentcenter.BuildInfo.targets from <Version> in the csproj — + /// bump it there when releasing, it is what the UpdateService compares against. + /// + public static string AppVersion => BuildInfo.Version; + + /// Commit this build came from — travels with error reports. + public static string GitCommitShort => BuildInfo.GitCommitShort; + + /// Dashboard grouping of the Watchdog monitor. + public const string MonitorGroup = "Applications"; + + /// Value for the "environment" field of the error stream. +#if DEBUG + public const string Environment = "development"; +#else + public const string Environment = "production"; +#endif +} diff --git a/src/Predictalytics.Hosting/DcErrorReporter.cs b/src/Predictalytics.Hosting/DcErrorReporter.cs new file mode 100644 index 0000000..1de065c --- /dev/null +++ b/src/Predictalytics.Hosting/DcErrorReporter.cs @@ -0,0 +1,252 @@ +using System.Diagnostics; +using Serilog; + +namespace Predictalytics.Hosting; + +/// +/// 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. +/// +public static class DcErrorReporter +{ + /// Server limit is 60 reports per minute and IP — stay well below it. + private const int MaxReportsPerMinute = 20; + + /// The same error is only reported again after this interval (the server counts it up anyway). + private static readonly TimeSpan RepeatSuppression = TimeSpan.FromMinutes(5); + + private static readonly object Sync = new(); + private static readonly Dictionary 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; + + /// (Re-)configures the reporter. An empty token switches it off. + 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; + } + } + + /// + /// 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. + /// + 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(); + }; + } + + /// + /// 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. + /// + 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); + } + + /// Reports a logged error that carries no exception (Log.Error("..." )). + 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"); + } + } + + /// Local budget: the server counts duplicates itself, we only avoid burning the rate limit. + 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; + } + + /// + /// AggregateException and TargetInvocationException say nothing about the actual defect; + /// grouping on them would throw unrelated errors into one bucket. + /// + private static Exception Unwrap(Exception exception) + { + while (exception is AggregateException { InnerExceptions.Count: 1 } aggregate) + { + exception = aggregate.InnerExceptions[0]; + } + return exception; + } + + /// Reads file and line from the first stack frame that has debug info (PDB present). + 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); + } + + /// Turns C:\build\...\src\Foo\Bar.cs into src/Foo/Bar.cs so the path matches the repo. + 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] + "…"; + } +} diff --git a/src/Predictalytics.Hosting/DcErrorSink.cs b/src/Predictalytics.Hosting/DcErrorSink.cs new file mode 100644 index 0000000..1a22223 --- /dev/null +++ b/src/Predictalytics.Hosting/DcErrorSink.cs @@ -0,0 +1,39 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Predictalytics.Hosting; + +/// +/// 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 — +/// is asked at emit time whether reporting is switched on, so +/// toggling the setting takes effect without rebuilding the logger. +/// +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); + } +} diff --git a/src/Predictalytics.Hosting/DcHeartbeatService.cs b/src/Predictalytics.Hosting/DcHeartbeatService.cs new file mode 100644 index 0000000..e8e8764 --- /dev/null +++ b/src/Predictalytics.Hosting/DcHeartbeatService.cs @@ -0,0 +1,217 @@ +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; +using Serilog; + +namespace Predictalytics.Hosting; + +/// Single self-assessed health check sent along with a heartbeat. +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; } +} + +/// What the application reports about itself in one heartbeat. +public sealed class DcHeartbeatSnapshot +{ + /// ok | warning | error — "stopped"/"maintenance" are sent by the service itself. + public string Status { get; set; } = "ok"; + + public string? Message { get; set; } + + /// A failing check downgrades an "ok" heartbeat to "warning" on the server. + public Dictionary Checks { get; } = new(); + + /// Numeric values; the server keeps 14 days of history per metric. + public Dictionary Metrics { get; } = new(); +} + +/// +/// 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". +/// +public sealed class DcHeartbeatService : IDisposable +{ + private readonly DcApiClient _api; + private readonly string _source; + private readonly string _instance; + private readonly int _intervalSeconds; + private readonly Func>? _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>? 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); + } + + /// + /// 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. + /// + 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. + } + } + + /// + /// 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. + /// + 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? checks, + Dictionary? 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 + }; +} diff --git a/src/Predictalytics.Hosting/DcUpdateService.cs b/src/Predictalytics.Hosting/DcUpdateService.cs new file mode 100644 index 0000000..0a3403a --- /dev/null +++ b/src/Predictalytics.Hosting/DcUpdateService.cs @@ -0,0 +1,58 @@ +using Deploymentcenter.Client; +using Serilog; + +namespace Predictalytics.Hosting; + +/// +/// 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. +/// +public static class DcUpdateService +{ + private const string AgentFileName = "update-agent.exe"; + + public static async Task 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); + } + + /// Path of the update agent next to the exe, or null if it was not deployed. + public static string? FindUpdateAgent() + { + var path = Path.Combine(AppContext.BaseDirectory, AgentFileName); + return File.Exists(path) ? path : null; + } + + /// + /// 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. + /// + 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); + } +} diff --git a/src/Predictalytics.Hosting/LicenseGuard.cs b/src/Predictalytics.Hosting/LicenseGuard.cs index ecf45de..d31e16a 100644 --- a/src/Predictalytics.Hosting/LicenseGuard.cs +++ b/src/Predictalytics.Hosting/LicenseGuard.cs @@ -3,94 +3,105 @@ using Serilog; namespace Predictalytics.Hosting; +/// An activated license: the key belonging to this machine plus the last result. +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; } +} + +/// Ergebnis der Pruefung des zwischengespeicherten Schluessels. +/// Gesetzt, wenn eine nutzbare Lizenz vorliegt. +/// Letzte Serverantwort — null, wenn gar kein Schluessel hinterlegt war. +public readonly record struct CachedLicenseCheck(LicenseSession? Session, LicenseValidationResult? LastResult) +{ + public bool IsUsable => Session is not null; +} + /// -/// Lizenzpruefung gegen das Deploymentcenter (dc.mhdf.de). +/// Startup license gate backed by the Deployment Center (POST /api/license/v1/validate). /// -/// 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 ): +/// a configurable endpoint would let anyone point the app at a fake license server. /// /// -/// 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. /// /// public static class LicenseGuard { - /// In dc_projects hinterlegter Slug. - public const string ProductSlug = "predictalytics"; - - private const string ServerUrl = "https://dc.mhdf.de"; - /// Re-check interval while the app is running (12 h). public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12); - private static readonly LicenseClient Client = new(); + public static LicenseClient CreateClient() => new(); - /// Hardware-ID v2 dieser Maschine (Format 2:<plattform>:<hex>). - public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(ProductSlug); + /// Hardware ID v2 of this machine — shown in the dialog and needed for support. + public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(DcConfig.ProductSlug); /// Ablageort des verschluesselten Lizenz-Caches. - public static string StorageDirectory => LicenseConfig.GetStorageDirectory(ProductSlug); + public static string StorageDirectory => LicenseConfig.GetStorageDirectory(DcConfig.ProductSlug); /// - /// 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). /// - public static string? GetCachedLicenseKey() + public static async Task 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); } - /// - /// Validiert einen Lizenzschluessel am Server und legt ihn bei Erfolg - /// verschluesselt ab. Bei Netzwerkfehlern faellt der Client auf den - /// Cache zurueck (Status valid_offline). - /// - public static Task ValidateAsync(string licenseKey) => - Client.ValidateAsync(ProductSlug, Normalize(licenseKey), ServerUrl); - - /// - /// Validiert mit dem zwischengespeicherten Schluessel. - /// Liefert null, wenn noch keiner hinterlegt ist. - /// - public static async Task ValidateCachedAsync() + /// Aktiviert einen neu eingegebenen Schluessel. + public static async Task<(LicenseSession? Session, LicenseValidationResult Result)> ActivateAsync( + LicenseClient client, string licenseKey) { - var key = GetCachedLicenseKey(); - return key == null ? null : await ValidateAsync(key); + 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); } - /// Gibt den Aktivierungsplatz dieser Maschine am Server wieder frei. - public static async Task DeactivateAsync() - { - var key = GetCachedLicenseKey(); - if (key == null) return false; - return await Client.DeactivateAsync(ProductSlug, key, ServerUrl); - } - - /// Schreibweise vereinheitlichen — die Schluessel sind durchgaengig gross. - public static string Normalize(string licenseKey) => licenseKey.Trim().ToUpperInvariant(); - /// - /// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf und - /// Ablauf waehrend des Betriebs. + /// Starts the periodic in-app revalidation. Detects revocation/expiry while the app keeps + /// running; only a definitive negative triggers — a server + /// outage must not. /// - /// - /// 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. - /// - public static IDisposable StartPeriodicRevalidation(Action onUnusable) + public static IDisposable StartPeriodicRevalidation( + LicenseSession session, Action 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); } + /// + /// 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. + /// + 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() diff --git a/src/Predictalytics.Hosting/LoggingSetup.cs b/src/Predictalytics.Hosting/LoggingSetup.cs index e07e52a..3b86534 100644 --- a/src/Predictalytics.Hosting/LoggingSetup.cs +++ b/src/Predictalytics.Hosting/LoggingSetup.cs @@ -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("══════════════════════════════════════════════════════"); } diff --git a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj index 1fb0588..f57a7ee 100644 --- a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj +++ b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj @@ -2,6 +2,9 @@ Predictalytics.Hosting + + 1.0.0 @@ -29,8 +32,12 @@ + Loest Watchdog + LicenseLabrador ab (Lizenz, UpdateService, Fehler-Stream, Bugtracker). --> + + + diff --git a/src/Predictalytics.Hosting/PredictalyticsOptions.cs b/src/Predictalytics.Hosting/PredictalyticsOptions.cs index 0df4fde..79fc389 100644 --- a/src/Predictalytics.Hosting/PredictalyticsOptions.cs +++ b/src/Predictalytics.Hosting/PredictalyticsOptions.cs @@ -8,8 +8,8 @@ namespace Predictalytics.Hosting; /// Laufzeit-Konfiguration der Anwendung. /// /// Die -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. /// /// 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 ─── diff --git a/src/Predictalytics.Hosting/WatchdogHeartbeatService.cs b/src/Predictalytics.Hosting/WatchdogHeartbeatService.cs deleted file mode 100644 index 1bb0b7b..0000000 --- a/src/Predictalytics.Hosting/WatchdogHeartbeatService.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Text; -using System.Text.Json; -using Serilog; - -namespace Predictalytics.Hosting; - -/// -/// 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. -/// -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? _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? 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); - } - - /// - /// 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. - /// - 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(); - } -} diff --git a/src/Predictalytics.Shell/App.axaml.cs b/src/Predictalytics.Shell/App.axaml.cs index c810c2b..ddc2270 100644 --- a/src/Predictalytics.Shell/App.axaml.cs +++ b/src/Predictalytics.Shell/App.axaml.cs @@ -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(); }; } + + /// + /// Gegenstueck zu Application.ThreadException in WinForms: eine im UI-Thread + /// durchgereichte Ausnahme wuerde den Prozess sonst spurlos beenden. + /// + private static void InstallUiThreadHandler() + { + Dispatcher.UIThread.UnhandledException += (_, e) => + { + DcErrorReporter.ReportUiThreadException(e.Exception); + e.Handled = true; + }; + } } diff --git a/src/Predictalytics.Shell/Services/HeadlessRunner.cs b/src/Predictalytics.Shell/Services/HeadlessRunner.cs index 237187b..2da17a7 100644 --- a/src/Predictalytics.Shell/Services/HeadlessRunner.cs +++ b/src/Predictalytics.Shell/Services/HeadlessRunner.cs @@ -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. /// - private static async Task EnsureLicensedAsync() + private static async Task 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) + { + 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 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 { - workersRunning = host.WorkersRunning, - webserverRunning = host.WebServerRunning - }); - watchdog.Start(); - return watchdog; + 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; } /// diff --git a/src/Predictalytics.Shell/Services/LicenseCli.cs b/src/Predictalytics.Shell/Services/LicenseCli.cs index 6484823..1accae3 100644 --- a/src/Predictalytics.Shell/Services/LicenseCli.cs +++ b/src/Predictalytics.Shell/Services/LicenseCli.cs @@ -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 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 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 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 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; } diff --git a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs index e7bf330..376ae0f 100644 --- a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs @@ -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; /// Wird gesetzt, sobald das Fenster steht — fuer Dialoge und Fehlermeldungen. public Func? 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() + /// + /// 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. + /// + 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); + } } /// Terminal-Sink: wird von Serilog aus beliebigen Threads gerufen. @@ -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(); + } + + /// + /// 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. + /// + private async Task CollectHeartbeatSnapshotAsync(CancellationToken ct) + { + var snapshot = new DcHeartbeatSnapshot + { + Message = _host.WorkersRunning ? "Worker laufen" : "Worker gestoppt" + }; + + // Deliberately no check for "workers stopped": that is a legitimate state chosen by + // the operator and would otherwise keep the monitor permanently on warning. + snapshot.Metrics["workers_running"] = _host.WorkersRunning ? 1 : 0; + snapshot.Metrics["webserver_running"] = _host.WebServerRunning ? 1 : 0; + snapshot.Metrics["memory_mb"] = Math.Round(GC.GetTotalMemory(forceFullCollection: false) / 1024d / 1024d, 1); + if (_lastDbSizeMb is { } dbSize) snapshot.Metrics["db_size_mb"] = Math.Round(dbSize, 2); + + var db = await ProbeDatabaseAsync(ct); + if (db is not null) snapshot.Checks["db"] = db; + + return snapshot; + } + + /// SELECT 1 against the configured MySQL, capped so it cannot stall the heartbeat. + private async Task 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 ─── + + /// + /// Asks the UpdateService for a newer release. Silent at startup (log + status bar); + /// only a critical release interrupts the user. + /// + [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) { - workersRunning = _host.WorkersRunning, - webserverRunning = _host.WebServerRunning - }); - _watchdog.Start(); + 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 } } - /// Beim Beenden: Watchdog abmelden, Worker und Webserver stoppen. + /// Beim Beenden: Monitor abmelden, Worker und Webserver stoppen. 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 */ } } diff --git a/src/Predictalytics.Shell/ViewModels/OptionSources.cs b/src/Predictalytics.Shell/ViewModels/OptionSources.cs index 24040ab..b45a8f5 100644 --- a/src/Predictalytics.Shell/ViewModels/OptionSources.cs +++ b/src/Predictalytics.Shell/ViewModels/OptionSources.cs @@ -16,4 +16,7 @@ public static class OptionSources MySqlSslMode.Preferred, MySqlSslMode.Required ]; + + /// Release-Kanaele des UpdateService. + public static string[] UpdateChannels { get; } = ["prod", "beta", "dev"]; } diff --git a/src/Predictalytics.Shell/Views/LicenseWindow.axaml b/src/Predictalytics.Shell/Views/LicenseWindow.axaml index 3ae5e93..cec3a4a 100644 --- a/src/Predictalytics.Shell/Views/LicenseWindow.axaml +++ b/src/Predictalytics.Shell/Views/LicenseWindow.axaml @@ -17,7 +17,7 @@ + PlaceholderText="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" /> diff --git a/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs b/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs index b6dc8ed..df658fa 100644 --- a/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs +++ b/src/Predictalytics.Shell/Views/LicenseWindow.axaml.cs @@ -8,20 +8,23 @@ namespace Predictalytics.Shell.Views; /// /// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt. -/// liefert true, sobald eine gueltige Lizenz vorliegt; -/// false, wenn der Benutzer abbricht. +/// liefert die Sitzung, sobald das Deployment Center die +/// Aktivierung fuer diese Maschine bestaetigt hat; null, wenn der Benutzer abbricht. /// public partial class LicenseWindow : Window { - private readonly TaskCompletionSource _completion = new(); + private readonly LicenseClient _client = null!; + private readonly TaskCompletionSource _completion = new(); /// Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde. - public Task Completion => _completion.Task; + public Task 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); } diff --git a/src/Predictalytics.Shell/Views/MainWindow.axaml b/src/Predictalytics.Shell/Views/MainWindow.axaml index 701ddf0..69c55ce 100644 --- a/src/Predictalytics.Shell/Views/MainWindow.axaml +++ b/src/Predictalytics.Shell/Views/MainWindow.axaml @@ -49,6 +49,10 @@ + + + + @@ -153,34 +157,62 @@ - - + + - - + + + + + + + + + + + + + + + - - + + - - - - - - - - - - + + - + + + + + + + + + + + + + + + + diff --git a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs deleted file mode 100644 index c10239f..0000000 --- a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs +++ /dev/null @@ -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; -} diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs deleted file mode 100644 index fd5a22e..0000000 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ /dev/null @@ -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; - - /// Exposes the terminal RichTextBox for the Serilog sink. - 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; - } - - /// - /// Called after Serilog is configured. Initializes the application host. - /// - 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(); - } - - /// - /// (Re-)creates the Watchdog heartbeat sender from the current settings. - /// Called at startup and whenever settings change. - /// - 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"); - } - } - - /// - /// 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. - /// - 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: —"); - } -} diff --git a/src/Predictalytics.WinFormsHost/MainForm.resx b/src/Predictalytics.WinFormsHost/MainForm.resx deleted file mode 100644 index f40f4f4..0000000 --- a/src/Predictalytics.WinFormsHost/MainForm.resx +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - 17, 17 - - - 162, 17 - - - 322, 17 - - \ No newline at end of file diff --git a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj b/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj deleted file mode 100644 index 327d3c8..0000000 --- a/src/Predictalytics.WinFormsHost/Predictalytics.WinFormsHost.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - WinExe - - net10.0-windows - true - Predictalytics.WinFormsHost - SystemAware - true - app.manifest - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - - - - diff --git a/src/Predictalytics.WinFormsHost/Program.cs b/src/Predictalytics.WinFormsHost/Program.cs deleted file mode 100644 index fffef08..0000000 --- a/src/Predictalytics.WinFormsHost/Program.cs +++ /dev/null @@ -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(); - } -} diff --git a/src/Predictalytics.WinFormsHost/Properties/launchSettings.json b/src/Predictalytics.WinFormsHost/Properties/launchSettings.json deleted file mode 100644 index 7769323..0000000 --- a/src/Predictalytics.WinFormsHost/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Predictalytics.WinFormsHost": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:62271;http://localhost:62272" - } - } -} \ No newline at end of file diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs b/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs deleted file mode 100644 index 00f9c7b..0000000 --- a/src/Predictalytics.WinFormsHost/Services/LicenseDialog.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Deploymentcenter.Client; -using Predictalytics.Hosting; - -namespace Predictalytics.WinFormsHost.Services; - -/// -/// 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. -/// -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; - } - } -} diff --git a/src/Predictalytics.WinFormsHost/Services/LicenseGate.cs b/src/Predictalytics.WinFormsHost/Services/LicenseGate.cs deleted file mode 100644 index 4b7a487..0000000 --- a/src/Predictalytics.WinFormsHost/Services/LicenseGate.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Predictalytics.Hosting; - -namespace Predictalytics.WinFormsHost.Services; - -/// -/// WinForms-seitige Lizenzschranke: verbindet den plattformneutralen -/// mit dem interaktiven Aktivierungsdialog. -/// -internal static class LicenseGate -{ - /// - /// 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. - /// - 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; - } -} diff --git a/src/Predictalytics.WinFormsHost/TerminalHelper.cs b/src/Predictalytics.WinFormsHost/TerminalHelper.cs deleted file mode 100644 index bc08e1b..0000000 --- a/src/Predictalytics.WinFormsHost/TerminalHelper.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Serilog.Events; - -namespace Predictalytics.WinFormsHost; - -/// -/// Creates a thread-safe write action for the RichTextBox terminal. -/// -public static class TerminalHelper -{ - public static Action 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 */ } - }; - } -} diff --git a/src/Predictalytics.WinFormsHost/app.manifest b/src/Predictalytics.WinFormsHost/app.manifest deleted file mode 100644 index b08a7f9..0000000 --- a/src/Predictalytics.WinFormsHost/app.manifest +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/Predictalytics.WinFormsHost/appsettings.json b/src/Predictalytics.WinFormsHost/appsettings.json deleted file mode 100644 index 7c70079..0000000 --- a/src/Predictalytics.WinFormsHost/appsettings.json +++ /dev/null @@ -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 - } -}